diff --git a/AGENTS.md b/AGENTS.md index f90f7302e7..9dd91a1a66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -304,9 +304,8 @@ plugin. The check is an entry whose `jarPath` still exists. ### A settings section can offer to install the plugin that serves it Two sections render a panel that belongs to a plugin - `Editor` and `Language servers`, both -editor-tab. (`Settings > AI Providers` was a third, served by secret-manager, until that section -moved into the plugin's own panel; Settings search reaches it through a **panel signpost** now, and -that entry is filtered on the panel being registered rather than explaining its absence.) Both used +editor-tab. AI provider search results use a **panel signpost** to the AI Gateway panel, filtered +on that panel being registered rather than explaining its absence. Both used to say "isn't loaded yet" for every reason there was no panel, which is true of exactly one of them. A plugin that was never installed, or that the user switched off, does not arrive however long they look at it. @@ -460,7 +459,7 @@ taking `first().replacementDisplayName` told the user their panel moved somewher `supabase/functions/boss-ai` serves multiple configured models through one authenticated endpoint. Configuration and accounting tables are service-role-only; every inference rechecks live per-model permissions and atomically reserves usage. -Secret Manager owns BOSS AI authentication and discovery. It requests a single-use ticket +AI Gateway owns BOSS AI authentication and discovery. It requests a single-use ticket through the existing authenticated RPC API and exchanges it with the edge function. The database derives identity from auth.uid(); only the service role can redeem tickets. No BOSS AI-specific desktop host registration or shared vault definition is required. @@ -537,46 +536,31 @@ implementation lives in `desktopMain` (it speaks HTTP), so `DefaultPlugin` reads self-healing. Nothing has ever read it from that file - it is an **environment variable**, and the priority order above does not apply to it. -- **AI providers** (chat, agents, plugin AI features) are owned entirely by the - **secret-manager** plugin, in the **AI section of its own panel**. The host has no provider - list and no settings section for one; it relays the plugin's through - `PluginContext.llmProvider`. See that plugin's `AGENTS.md`. +- **AI providers** (chat, agents, plugin AI features) are owned entirely by the **ai-gateway** + plugin. Its panel owns provider setup, enabled models, usage, and token accounting. Secret + Manager owns only encrypted storage and the human grant UI. - The host used to render `Settings > AI Providers` from that plugin, through a - `LlmProviderAPIAccess` singleton. Both are gone: the credentials live in that panel's vault, - so the page that manages them belongs beside them rather than two clicks away in another - window, and the singleton existed only to give host composables a plugin handle. What remains - is `DefaultPlugin.llmProvider`, which resolves against **its own** instance's registry - - deliberately never through a singleton, because `DefaultPlugin` is per window and a shared - cached reference would hand window 1's plugins whatever window 2 registered. + Provider credentials cross the host through `SecretAccessProvider`, bound to the calling + plugin or exact MCP tool. Ownerless legacy secrets are invisible to execution principals until + a human explicitly grants use. Grants never confer update or delete rights. The broad + `SecretDataProvider` remains restricted to trusted human surfaces. A stale `boss://settings?section=LLM_PROVIDERS` deep link now resolves to `SettingsDeepLink.Unresolved`, so the window opens on its default section rather than failing. Settings search still answers for `api key`, `anthropic`, `claude` and the rest: a curated - **panel signpost** (`panelSignpost` in `SettingsSearchEntries.kt`) opens the Secret Manager - panel and raises the main window. It is the only search entry that navigates out of the + **panel signpost** (`panelSignpost` in `SettingsSearchEntries.kt`) opens the AI Gateway panel + and raises the main window. It is the only search entry that navigates out of the Settings window. The delegated-section keywords could not have covered this - a panel is not a settings page, so nothing merges it into the index at query time. - **There is deliberately no version floor on secret-manager.** The AI section exists in that - plugin only from 1.2.19, and nothing in the host gates on it: secret-manager is not in the - `system_plugins` manifest, so no `min_version` applies, and plugin updates surface in the - Toolbox rather than installing themselves. A user on 1.2.18 who takes this host build gets the - Secret Manager panel with no AI section in it. That is accepted rather than overlooked, and it - is a weaker case than `RetiredPlugins.minReplacementVersion`, which names a release because - getting it wrong **deletes** the user's only secrets panel. Here nothing is deleted and nothing - is lost - the credentials stay in the vault, `PluginContext.llmProvider` keeps serving them to - every plugin that asks, and updating the plugin restores the page. The floor would have to be - enforced somewhere, and the only mechanism the host has for that is refusing to load the - plugin, which would take the vault down with it. - **AI self-healing / repair** is the one credential the host still resolves itself, because `SelfHealingSettingsManager` runs before any window or plugin exists and so cannot reach the plugin's store. It reads `AI_REPAIR_API_KEY`, then the provider's own variable (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / …), then the legacy `~/.boss/llm_settings.json` and its `.migrated` sibling - all as **env vars / files, never local.properties**. A key rotated - in the Secret Manager panel's AI section does not reach it. + in the AI Gateway panel does not reach it. There are NO credential fallbacks in source (public repo). Packaged builds get the JxBrowser license and Supabase settings baked in by the diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PanelIds.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PanelIds.kt index c8a55cc0c5..c16132ef7d 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PanelIds.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PanelIds.kt @@ -23,6 +23,7 @@ object PanelIds { // Admin/Security panels val SECRET_MANAGER = PanelId("secret-manager", 2) + val AI_GATEWAY = PanelId("ai-gateway", 25) val ADMIN_ROLE_MANAGEMENT = PanelId("admin-role-management", 2) val ROLE_CREATION = PanelId("role-creation", 2) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/TrackingPluginContext.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/TrackingPluginContext.kt index d8378c1382..7b32e64a30 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/TrackingPluginContext.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/TrackingPluginContext.kt @@ -34,6 +34,7 @@ import ai.rever.boss.plugin.api.PanelInfo import ai.rever.boss.plugin.api.PanelRegistry import ai.rever.boss.plugin.api.PerformanceDataProvider import ai.rever.boss.plugin.api.PluginContext +import ai.rever.boss.plugin.api.PluginLoaderDelegate import ai.rever.boss.plugin.api.PluginManifest import ai.rever.boss.plugin.api.PluginSandboxRef import ai.rever.boss.plugin.api.PluginStorageFactory @@ -43,7 +44,10 @@ import ai.rever.boss.plugin.api.ProjectSearchProvider import ai.rever.boss.plugin.api.RoleManagementProvider import ai.rever.boss.plugin.api.RunConfigurationDataProvider import ai.rever.boss.plugin.api.ScreenCaptureProvider +import ai.rever.boss.plugin.api.SecretAccessProvider import ai.rever.boss.plugin.api.SecretDataProvider +import ai.rever.boss.plugin.api.SecretGrantManager +import ai.rever.boss.plugin.api.SecretPrincipalData import ai.rever.boss.plugin.api.SemanticTokenProvider import ai.rever.boss.plugin.api.SettingsProvider import ai.rever.boss.plugin.api.SplitViewOperations @@ -59,10 +63,20 @@ import ai.rever.boss.plugin.api.UserManagementProvider import ai.rever.boss.plugin.api.WorkspaceDataProvider import ai.rever.boss.plugin.api.ZoomSettingsProvider import ai.rever.boss.plugin.browser.BrowserService +import ai.rever.boss.services.supabase.SecretAccessProviderImpl +import ai.rever.boss.services.supabase.SecretExecutionPrincipal +import ai.rever.boss.services.supabase.SecretGrantManagerImpl import com.arkivanov.decompose.ComponentContext import kotlinx.coroutines.CoroutineScope import java.util.concurrent.ConcurrentHashMap +private const val SECRET_MANAGER_PLUGIN_ID = "ai.rever.boss.plugin.dynamic.secretmanager" +private val HUMAN_SECRET_PROVIDER_PLUGINS = + setOf( + SECRET_MANAGER_PLUGIN_ID, + "ai.rever.boss.plugin.dynamic.fluckbrowser", + ) + /** * Registry of all registrations made by dynamic plugins. * @@ -333,7 +347,27 @@ class TrackingPluginContext( override val gitDataProvider: GitDataProvider? get() = delegate.gitDataProvider override val projectSearchProvider: ProjectSearchProvider? get() = delegate.projectSearchProvider override val fileSystemDataProvider: FileSystemDataProvider? get() = delegate.fileSystemDataProvider - override val secretDataProvider: SecretDataProvider? get() = delegate.secretDataProvider + + // The broad provider is a human-vault surface. Ordinary plugins must not be able to list it; + // they receive the principal-scoped provider below. Browser filling is a trusted human action + // that writes directly into the page and Secret Manager is the human administration UI. + override val secretDataProvider: SecretDataProvider? + get() = delegate.secretDataProvider.takeIf { pluginId in HUMAN_SECRET_PROVIDER_PLUGINS } + + override val secretAccessProvider: SecretAccessProvider by lazy { + SecretAccessProviderImpl(SecretExecutionPrincipal.plugin(pluginId)) + } + + private val scopedSecretGrantManager: SecretGrantManager? by lazy { + if (pluginId == SECRET_MANAGER_PLUGIN_ID) { + SecretGrantManagerImpl(::secretPrincipalCatalog) + } else { + null + } + } + + override val secretGrantManager: SecretGrantManager? + get() = scopedSecretGrantManager override val llmProvider: LlmProvider? get() = delegate.llmProvider override val brokeredCredentialProvider: BrokeredCredentialProvider? get() = delegate.brokeredCredentialProvider @@ -344,7 +378,15 @@ class TrackingPluginContext( override val authDataProvider: AuthDataProvider? get() = delegate.authDataProvider override val userManagementProvider: UserManagementProvider? get() = delegate.userManagementProvider override val roleManagementProvider: RoleManagementProvider? get() = delegate.roleManagementProvider - override val supabaseDataProvider: SupabaseDataProvider? get() = delegate.supabaseDataProvider + override val supabaseDataProvider: SupabaseDataProvider? by lazy { + delegate.supabaseDataProvider?.let { provider -> + if (pluginId == SECRET_MANAGER_PLUGIN_ID) { + provider + } else { + SecretSafeSupabaseDataProvider(provider) + } + } + } override val panelEventProvider: PanelEventProvider? get() = delegate.panelEventProvider override val settingsProvider: SettingsProvider? get() = delegate.settingsProvider @@ -358,6 +400,37 @@ class TrackingPluginContext( // Plugin Store API key provider - delegate to underlying context override val pluginStoreApiKeyProvider: PluginStoreApiKeyProvider? get() = delegate.pluginStoreApiKeyProvider + private fun secretPrincipalCatalog(): List { + val plugins = + runCatching { + delegate + .getPluginAPI(PluginLoaderDelegate::class.java) + ?.getLoadedPlugins() + .orEmpty() + .filter { it.isEnabled && it.healthy && !it.isIncompatible } + .map { + SecretPrincipalData( + principalType = "plugin", + principalId = it.pluginId, + displayName = it.displayName, + description = it.description.takeIf(String::isNotBlank), + ) + } + }.getOrDefault(emptyList()) + val tools = + runCatching { + delegate.mcpToolRegistry?.allTools?.value.orEmpty().map { + SecretPrincipalData( + principalType = "mcp_tool", + principalId = "${it.providerId}/${it.definition.name}", + displayName = it.definition.name, + description = it.definition.description, + ) + } + }.getOrDefault(emptyList()) + return plugins + tools + } + // Tab update provider factory - delegate to underlying context override val tabUpdateProviderFactory: TabUpdateProviderFactory? get() = delegate.tabUpdateProviderFactory @@ -503,8 +576,16 @@ class TrackingPluginContext( delegate.unregisterStatusBarItem(itemId) } - // Plugin-to-plugin API access - delegate to underlying context - override fun getPluginAPI(apiClass: Class): T? = delegate.getPluginAPI(apiClass) + // Plugin-to-plugin API access must preserve the same secret boundary as the typed properties. + // Otherwise a plugin could ask the registry for the broad provider and bypass its bound principal. + override fun getPluginAPI(apiClass: Class): T? = + when (apiClass) { + SecretDataProvider::class.java -> secretDataProvider?.let(apiClass::cast) + SecretAccessProvider::class.java -> apiClass.cast(secretAccessProvider) + SecretGrantManager::class.java -> secretGrantManager?.let(apiClass::cast) + SupabaseDataProvider::class.java -> supabaseDataProvider?.let(apiClass::cast) + else -> delegate.getPluginAPI(apiClass) + } override fun registerPluginAPI(api: Any) = delegate.registerPluginAPI(api) @@ -557,6 +638,42 @@ class TrackingPluginContext( } } +/** + * Prevents ordinary plugins from reaching secret tables or RPC functions through the generic + * database escape hatch. Secret operations must use [SecretAccessProvider], which binds every + * request to the host-derived plugin or MCP-tool principal. + */ +internal class SecretSafeSupabaseDataProvider( + private val delegate: SupabaseDataProvider, +) : SupabaseDataProvider { + override suspend fun select( + table: String, + columns: String, + filters: List, + range: ai.rever.boss.plugin.api.QueryRange?, + ): Result = + if (table.isSecretSurface()) { + Result.failure(SecurityException("Secret tables are available only through SecretAccessProvider")) + } else { + delegate.select(table, columns, filters, range) + } + + override suspend fun rpc( + function: String, + parameters: String, + ): Result = + if (function.isSecretSurface()) { + Result.failure(SecurityException("Secret RPCs are available only through SecretAccessProvider")) + } else { + delegate.rpc(function, parameters) + } + + private fun String.isSecretSurface(): Boolean { + val normalized = lowercase().filter { it.isLetterOrDigit() || it == '_' } + return normalized.contains("secret") || normalized.contains("credential") + } +} + /** * Binds [PluginStorageFactory.createStorage] to the plugin that owns this context, * ignoring whatever pluginId the caller passes in. diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpToolRegistryImpl.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpToolRegistryImpl.kt index b62d5c52bb..4958ed03ee 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpToolRegistryImpl.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpToolRegistryImpl.kt @@ -10,6 +10,8 @@ import ai.rever.boss.plugin.api.McpToolResult import ai.rever.boss.plugin.api.RegisteredMcpTool import ai.rever.boss.plugin.logging.LogSanitizer import ai.rever.boss.plugin.pathutils.BossDirectories +import ai.rever.boss.services.supabase.SecretExecutionPrincipal +import ai.rever.boss.services.supabase.SecretExecutionPrincipalContext import ai.rever.boss.utils.atomicWriteText import ai.rever.boss.utils.logging.BossLogger import ai.rever.boss.utils.logging.LogCategory @@ -1039,7 +1041,16 @@ internal class McpToolRegistryCore( @Suppress("TooGenericExceptionCaught") // Plugin handlers may throw any implementation-specific exception. private suspend fun executeUncapped(tool: RegisteredMcpTool, args: McpToolArgs): McpToolResult = try { - withTimeout(invokeTimeoutMs) { tool.definition.handler.call(args) } + // Narrow any SecretAccessProvider captured by the plugin to this exact MCP tool for + // the full handler coroutine. The identity comes from the host registry entry, never + // from model-controlled arguments or a provider-supplied display name. + withContext( + SecretExecutionPrincipalContext( + SecretExecutionPrincipal.mcpTool(tool.providerId, tool.definition.name), + ), + ) { + withTimeout(invokeTimeoutMs) { tool.definition.handler.call(args) } + } } catch (_: TimeoutCancellationException) { McpToolResult("Tool '${tool.definition.name}' timed out after ${invokeTimeoutMs / 1000}s", isError = true) } catch (cancelled: CancellationException) { diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/supabase/ExecutionSecretService.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/supabase/ExecutionSecretService.kt new file mode 100644 index 0000000000..e6377227d6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/supabase/ExecutionSecretService.kt @@ -0,0 +1,190 @@ +package ai.rever.boss.services.supabase + +import ai.rever.boss.plugin.api.CreateSecretRequestData +import ai.rever.boss.plugin.api.UpdateSecretRequestData +import ai.rever.boss.services.supabase.models.ExecutionSecretGrant +import ai.rever.boss.services.supabase.models.ExecutionSecretMetadata +import ai.rever.boss.services.supabase.models.SecretEntry +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory +import io.github.jan.supabase.postgrest.postgrest +import io.github.jan.supabase.postgrest.rpc +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.put + +/** Supabase RPC client for the execution-principal secret boundary. */ +object ExecutionSecretService { + private val logger = BossLogger.forComponent("ExecutionSecretService") + + private val client + get() = SupabaseConfig.client + + suspend fun list( + principal: SecretExecutionPrincipal, + query: String?, + limit: Int, + offset: Int, + ): Result> = + rpcList("list_execution_secrets") { + principalParameters(principal) + if (query != null) put("p_query", query) + put("p_limit", limit) + put("p_offset", offset) + } + + suspend fun get( + principal: SecretExecutionPrincipal, + secretId: String, + ): Result = + rpcList("get_execution_secret") { + principalParameters(principal) + put("p_secret_id", secretId) + }.map { it.firstOrNull() } + + suspend fun create( + principal: SecretExecutionPrincipal, + request: CreateSecretRequestData, + ): Result = + mutation("create_execution_secret") { + principalParameters(principal) + requestParameters(request) + }.mapCatching { it.secretId ?: error("Secret store returned no id") } + + suspend fun update( + principal: SecretExecutionPrincipal, + request: UpdateSecretRequestData, + ): Result = + mutation("update_execution_secret") { + principalParameters(principal) + put("p_secret_id", request.secretId) + requestParameters(request) + }.map { } + + suspend fun delete( + principal: SecretExecutionPrincipal, + secretId: String, + ): Result = + mutation("delete_execution_secret") { + principalParameters(principal) + put("p_secret_id", secretId) + }.map { } + + suspend fun listGrants(secretId: String): Result> = + rpcList("list_secret_execution_grants") { put("p_secret_id", secretId) } + + suspend fun grant( + secretId: String, + principalType: String, + principalId: String, + ): Result = grantMutation("grant_secret_to_execution_principal", secretId, principalType, principalId) + + suspend fun revoke( + secretId: String, + principalType: String, + principalId: String, + ): Result = grantMutation("revoke_secret_from_execution_principal", secretId, principalType, principalId) + + private suspend fun grantMutation( + operation: String, + secretId: String, + principalType: String, + principalId: String, + ): Result = + mutation(operation) { + put("p_secret_id", secretId) + put("p_principal_type", principalType) + put("p_principal_id", principalId) + }.map { } + + private suspend inline fun rpcList( + operation: String, + parameters: kotlinx.serialization.json.JsonObjectBuilder.() -> Unit, + ): Result> = + try { + val response = client.postgrest.rpc(operation, buildJsonObject(parameters)) + Result.success( + supabaseJson.decodeFromJsonElement( + supabaseJson.parseToJsonElement(response.data), + ), + ) + } catch (error: Exception) { + failed(operation, error) + } + + private suspend fun mutation( + operation: String, + parameters: kotlinx.serialization.json.JsonObjectBuilder.() -> Unit, + ): Result = + try { + val response = client.postgrest.rpc(operation, buildJsonObject(parameters)) + val decoded = + supabaseJson.decodeFromJsonElement( + supabaseJson.parseToJsonElement(response.data), + ) + if (decoded.success) { + Result.success(decoded) + } else { + Result.failure(IllegalStateException(decoded.error ?: "Secret operation refused")) + } + } catch (error: Exception) { + failed(operation, error) + } + + private fun kotlinx.serialization.json.JsonObjectBuilder.principalParameters(principal: SecretExecutionPrincipal) { + put("p_principal_type", principal.type) + put("p_principal_id", principal.id) + } + + private fun kotlinx.serialization.json.JsonObjectBuilder.requestParameters(request: CreateSecretRequestData) { + put("p_website", request.website) + put("p_username", request.username) + put("p_password", request.password) + request.notes?.let { put("p_notes", it) } + request.expirationDate?.let { put("p_expiration_date", it) } + if (request.tags.isNotEmpty()) put("p_tags", JsonArray(request.tags.map(::JsonPrimitive))) + put("p_twofa_enabled", request.twofaEnabled) + request.twofaType?.let { put("p_twofa_type", it) } + if (request.recoveryCodes.isNotEmpty()) { + put("p_recovery_codes", JsonArray(request.recoveryCodes.map(::JsonPrimitive))) + } + } + + private fun kotlinx.serialization.json.JsonObjectBuilder.requestParameters(request: UpdateSecretRequestData) = + requestParameters( + CreateSecretRequestData( + website = request.website, + username = request.username, + password = request.password, + notes = request.notes, + expirationDate = request.expirationDate, + tags = request.tags, + twofaEnabled = request.twofaEnabled, + twofaType = request.twofaType, + recoveryCodes = request.recoveryCodes, + ), + ) + + private fun failed( + operation: String, + error: Exception, + ): Result { + logger.warn( + LogCategory.NETWORK, + "Execution-principal secret RPC failed", + data = mapOf("operation" to operation, "errorType" to error::class.simpleName), + ) + return Result.failure(sanitizeSupabaseFailure(operation, error)) + } + + @Serializable + private data class MutationResponse( + val success: Boolean, + val error: String? = null, + @kotlinx.serialization.SerialName("secret_id") + val secretId: String? = null, + ) +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/supabase/SecretAccessProviderImpl.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/supabase/SecretAccessProviderImpl.kt new file mode 100644 index 0000000000..c4dfdfa3b5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/supabase/SecretAccessProviderImpl.kt @@ -0,0 +1,225 @@ +package ai.rever.boss.services.supabase + +import ai.rever.boss.plugin.api.AccessibleSecretMetadata +import ai.rever.boss.plugin.api.CreateSecretRequestData +import ai.rever.boss.plugin.api.PaginatedAccessibleSecrets +import ai.rever.boss.plugin.api.SecretAccessProvider +import ai.rever.boss.plugin.api.SecretEntryData +import ai.rever.boss.plugin.api.SecretGrantManager +import ai.rever.boss.plugin.api.SecretMetadataData +import ai.rever.boss.plugin.api.SecretPrincipalData +import ai.rever.boss.plugin.api.SecretPrincipalGrantData +import ai.rever.boss.plugin.api.UpdateSecretRequestData +import ai.rever.boss.services.supabase.models.ExecutionSecretGrant +import ai.rever.boss.services.supabase.models.ExecutionSecretMetadata +import ai.rever.boss.services.supabase.models.SecretEntry +import kotlinx.coroutines.currentCoroutineContext +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** Host-derived identity used for secret authorization. Never constructed from plugin arguments. */ +data class SecretExecutionPrincipal( + val type: String, + val id: String, +) { + init { + require(type.isNotBlank()) { "Secret principal type cannot be blank" } + require(id.isNotBlank()) { "Secret principal id cannot be blank" } + } + + companion object { + fun plugin(pluginId: String) = SecretExecutionPrincipal("plugin", pluginId) + + fun mcpTool( + providerId: String, + toolName: String, + ) = SecretExecutionPrincipal("mcp_tool", "$providerId/$toolName") + } +} + +/** Invocation-scoped narrowing installed by trusted host dispatchers such as the MCP registry. */ +class SecretExecutionPrincipalContext( + val principal: SecretExecutionPrincipal, +) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key +} + +/** Principal-bound implementation exposed to ordinary plugin contexts. */ +class SecretAccessProviderImpl internal constructor( + private val pluginPrincipal: SecretExecutionPrincipal, + private val backend: ExecutionSecretBackend = SupabaseExecutionSecretBackend, +) : SecretAccessProvider { + override suspend fun listSecrets( + limit: Int, + offset: Int, + ): Result = list(query = null, limit = limit, offset = offset) + + override suspend fun searchSecrets( + query: String, + limit: Int, + offset: Int, + ): Result = list(query = query, limit = limit, offset = offset) + + override suspend fun getSecret(secretId: String): Result = + backend.get(principal(), secretId).map { it?.toPluginData() } + + override suspend fun createSecret(request: CreateSecretRequestData): Result = backend.create(principal(), request) + + override suspend fun updateOwnedSecret(request: UpdateSecretRequestData): Result = backend.update(principal(), request) + + override suspend fun deleteOwnedSecret(secretId: String): Result = backend.delete(principal(), secretId) + + private suspend fun list( + query: String?, + limit: Int, + offset: Int, + ): Result { + val bounded = limit.coerceIn(1, 500) + return backend.list(principal(), query, bounded, offset.coerceAtLeast(0)).map { + PaginatedAccessibleSecrets( + data = it.map(ExecutionSecretMetadata::toPluginData), + hasMore = it.size >= bounded, + ) + } + } + + private suspend fun principal(): SecretExecutionPrincipal = + currentCoroutineContext()[SecretExecutionPrincipalContext]?.principal ?: pluginPrincipal +} + +internal interface ExecutionSecretBackend { + suspend fun list( + principal: SecretExecutionPrincipal, + query: String?, + limit: Int, + offset: Int, + ): Result> + + suspend fun get( + principal: SecretExecutionPrincipal, + secretId: String, + ): Result + + suspend fun create( + principal: SecretExecutionPrincipal, + request: CreateSecretRequestData, + ): Result + + suspend fun update( + principal: SecretExecutionPrincipal, + request: UpdateSecretRequestData, + ): Result + + suspend fun delete( + principal: SecretExecutionPrincipal, + secretId: String, + ): Result +} + +private object SupabaseExecutionSecretBackend : ExecutionSecretBackend { + override suspend fun list( + principal: SecretExecutionPrincipal, + query: String?, + limit: Int, + offset: Int, + ) = ExecutionSecretService.list(principal, query, limit, offset) + + override suspend fun get( + principal: SecretExecutionPrincipal, + secretId: String, + ) = ExecutionSecretService.get(principal, secretId) + + override suspend fun create( + principal: SecretExecutionPrincipal, + request: CreateSecretRequestData, + ) = ExecutionSecretService.create(principal, request) + + override suspend fun update( + principal: SecretExecutionPrincipal, + request: UpdateSecretRequestData, + ) = ExecutionSecretService.update(principal, request) + + override suspend fun delete( + principal: SecretExecutionPrincipal, + secretId: String, + ) = ExecutionSecretService.delete(principal, secretId) +} + +/** Human-only grant administration, exposed solely to the trusted Secret Manager context. */ +class SecretGrantManagerImpl( + private val principalCatalog: () -> List, +) : SecretGrantManager { + override suspend fun listPrincipals(): Result> = + Result.success( + principalCatalog() + .distinctBy { it.principalType to it.principalId } + .sortedWith(compareBy(SecretPrincipalData::displayName, SecretPrincipalData::principalId)), + ) + + override suspend fun listGrants(secretId: String): Result> = + ExecutionSecretService.listGrants(secretId).map { grants -> + grants.map(ExecutionSecretGrant::toPluginData) + } + + override suspend fun grantSecret( + secretId: String, + principalType: String, + principalId: String, + ): Result { + val known = + principalCatalog().any { + it.principalType == principalType && it.principalId == principalId + } + if (!known) return Result.failure(IllegalArgumentException("Unknown secret principal")) + return ExecutionSecretService.grant(secretId, principalType, principalId) + } + + override suspend fun revokeSecret( + secretId: String, + principalType: String, + principalId: String, + ): Result = ExecutionSecretService.revoke(secretId, principalType, principalId) +} + +private fun ExecutionSecretMetadata.toPluginData(): AccessibleSecretMetadata = + AccessibleSecretMetadata( + id = id, + website = website, + username = username, + expirationDate = expirationDate, + tags = tags, + createdAt = createdAt, + updatedAt = updatedAt, + accessLevel = accessLevel, + ) + +private fun SecretEntry.toPluginData(): SecretEntryData = + SecretEntryData( + id = id, + website = website, + username = username, + password = password, + notes = notes, + expirationDate = expirationDate, + tags = tags, + metadata = + metadata?.let { + SecretMetadataData( + twofaEnabled = it.twofaEnabled, + twofaType = it.twofaType, + twofaSecret = it.twofaSecret, + recoveryCodes = it.recoveryCodes, + ) + }, + createdAt = createdAt, + updatedAt = updatedAt, + ) + +private fun ExecutionSecretGrant.toPluginData(): SecretPrincipalGrantData = + SecretPrincipalGrantData( + principalType = principalType, + principalId = principalId, + grantedAt = grantedAt, + grantedByUserId = grantedByUserId, + accessLevel = accessLevel, + ) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/supabase/models/SecretModels.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/supabase/models/SecretModels.kt index e31d8508f2..167bde754b 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/supabase/models/SecretModels.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/supabase/models/SecretModels.kt @@ -44,6 +44,38 @@ data class SecretEntry( val canManageOrDeny: Boolean get() = canManage == true } +/** Non-secret row returned to an execution principal's list/search calls. */ +@Serializable +data class ExecutionSecretMetadata( + val id: String, + val website: String, + val username: String, + @SerialName("expiration_date") + val expirationDate: String? = null, + val tags: List = emptyList(), + @SerialName("created_at") + val createdAt: String, + @SerialName("updated_at") + val updatedAt: String, + @SerialName("access_level") + val accessLevel: String, +) + +/** One explicit human grant returned by list_secret_execution_grants. */ +@Serializable +data class ExecutionSecretGrant( + @SerialName("principal_type") + val principalType: String, + @SerialName("principal_id") + val principalId: String, + @SerialName("granted_at") + val grantedAt: String, + @SerialName("granted_by_user_id") + val grantedByUserId: String, + @SerialName("access_level") + val accessLevel: String = "use", +) + /** * Metadata for a secret (2FA information) */ diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/search/SettingsSearchEntries.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/search/SettingsSearchEntries.kt index 3ec1223587..82957fe833 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/search/SettingsSearchEntries.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/search/SettingsSearchEntries.kt @@ -170,23 +170,21 @@ private fun panelSignpost( /** * Settings that used to be in this window and are now somewhere else. * - * AI providers were `Settings > AI Providers` until the section was removed: the credentials live - * in the Secret Manager panel's vault and the page that manages them moved beside them. Deleting + * AI providers were `Settings > AI Providers` until the section was removed. AI Gateway now owns + * provider and model configuration while Secret Manager only stores encrypted values. Deleting * the section without this left the words a user actually types - "api key", "anthropic", "claude" * - matching nothing, so Settings search answered "No matching settings" for a feature that exists. * * The first six keywords are the ones the deleted `delegated(LLM_PROVIDERS, ...)` entry carried. * The rest name the panel itself, because [SettingsSearchMatcher] scores label, group, section - * display name and keywords - **not** `context`. Without them the breadcrumb reads "Secret Manager - * panel" and yet "secret manager" matched nothing, which is the likeliest thing to be typed by - * someone who half-remembers that the keys live in a vault. + * display name and keywords - **not** `context`. */ private fun signpostEntries() = listOf( panelSignpost( label = "AI Providers", - panel = PanelIds.SECRET_MANAGER, - owner = "Secret Manager panel", + panel = PanelIds.AI_GATEWAY, + owner = "AI Gateway panel", "api key", "anthropic", "openai", @@ -194,8 +192,9 @@ private fun signpostEntries() = "claude", "gateway", "llm", - "secret manager", - "vault", + "ai gateway", + "usage", + "tokens", "credentials", ), ) diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/SecretSafeSupabaseDataProviderTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/SecretSafeSupabaseDataProviderTest.kt new file mode 100644 index 0000000000..17964f0467 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/SecretSafeSupabaseDataProviderTest.kt @@ -0,0 +1,56 @@ +package ai.rever.boss.components.plugin + +import ai.rever.boss.plugin.api.QueryFilter +import ai.rever.boss.plugin.api.QueryRange +import ai.rever.boss.plugin.api.SupabaseDataProvider +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SecretSafeSupabaseDataProviderTest { + @Test + fun `blocks secret tables and functions without touching delegate`() = + runTest { + val delegate = RecordingSupabase() + val provider = SecretSafeSupabaseDataProvider(delegate) + + assertTrue(provider.select("secret_execution_grants").isFailure) + assertTrue(provider.rpc("get_user_secrets").isFailure) + assertTrue(provider.rpc("read_credential").isFailure) + assertEquals(0, delegate.calls) + } + + @Test + fun `keeps unrelated plugin database operations available`() = + runTest { + val delegate = RecordingSupabase() + val provider = SecretSafeSupabaseDataProvider(delegate) + + assertEquals("[]", provider.select("projects").getOrThrow()) + assertEquals("{}", provider.rpc("boss_ai_create_exchange_ticket").getOrThrow()) + assertEquals(2, delegate.calls) + } +} + +private class RecordingSupabase : SupabaseDataProvider { + var calls = 0 + + override suspend fun select( + table: String, + columns: String, + filters: List, + range: QueryRange?, + ): Result { + calls++ + return Result.success("[]") + } + + override suspend fun rpc( + function: String, + parameters: String, + ): Result { + calls++ + return Result.success("{}") + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/settings/search/SettingsSearchIndexShapeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/settings/search/SettingsSearchIndexShapeTest.kt index 43a0ed238e..55548b6695 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/settings/search/SettingsSearchIndexShapeTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/settings/search/SettingsSearchIndexShapeTest.kt @@ -150,8 +150,8 @@ class SettingsSearchIndexShapeTest { val hits = SettingsSearchMatcher.search(query, SettingsSearchIndex.builtIn) assertTrue( - hits.any { it.entry.panel == PanelIds.SECRET_MANAGER }, - "searching '$query' no longer offers the Secret Manager panel: ${hits.map { it.entry.label }}", + hits.any { it.entry.panel == PanelIds.AI_GATEWAY }, + "searching '$query' no longer offers the AI Gateway panel: ${hits.map { it.entry.label }}", ) } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/settings/search/SettingsSidebarSearchTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/settings/search/SettingsSidebarSearchTest.kt index 41772576d1..8711ea4443 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/settings/search/SettingsSidebarSearchTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/settings/search/SettingsSidebarSearchTest.kt @@ -119,11 +119,11 @@ class SettingsSidebarSearchTest { } compose.onNodeWithText("AI Providers").assertExists() - compose.onAllNodesWithText("Plugins > Secret Manager panel").onFirst().assertExists() + compose.onAllNodesWithText("Plugins > AI Gateway panel").onFirst().assertExists() compose.onNodeWithText("AI Providers").performClick() - assertEquals(PanelIds.SECRET_MANAGER, picked?.panel) + assertEquals(PanelIds.AI_GATEWAY, picked?.panel) assertEquals(null, picked?.section, "a signpost that names a section would navigate this window instead") } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretAccessProviderImplTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretAccessProviderImplTest.kt new file mode 100644 index 0000000000..4187780fce --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretAccessProviderImplTest.kt @@ -0,0 +1,101 @@ +package ai.rever.boss.services.supabase + +import ai.rever.boss.plugin.api.CreateSecretRequestData +import ai.rever.boss.plugin.api.UpdateSecretRequestData +import ai.rever.boss.services.supabase.models.ExecutionSecretMetadata +import ai.rever.boss.services.supabase.models.SecretEntry +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals + +class SecretAccessProviderImplTest { + @Test + fun `plugin calls use bound host principal`() = + runTest { + val backend = RecordingBackend() + val provider = SecretAccessProviderImpl(SecretExecutionPrincipal.plugin("plugin-a"), backend) + + provider.listSecrets() + provider.getSecret("secret-1") + provider.createSecret(CreateSecretRequestData("example.com", "user", "value")) + + assertEquals(List(3) { SecretExecutionPrincipal.plugin("plugin-a") }, backend.principals) + } + + @Test + fun `mcp invocation narrows a captured plugin provider to exact tool principal`() = + runTest { + val backend = RecordingBackend() + val provider = SecretAccessProviderImpl(SecretExecutionPrincipal.plugin("secret-manager"), backend) + val tool = SecretExecutionPrincipal.mcpTool("secret-manager", "secret_get") + + withContext(SecretExecutionPrincipalContext(tool)) { + provider.getSecret("secret-1") + } + + assertEquals(listOf(tool), backend.principals) + } + + @Test + fun `list bounds untrusted pagination`() = + runTest { + val backend = RecordingBackend() + val provider = SecretAccessProviderImpl(SecretExecutionPrincipal.plugin("plugin-a"), backend) + + provider.listSecrets(limit = Int.MAX_VALUE, offset = -10) + + assertEquals(500, backend.limit) + assertEquals(0, backend.offset) + } +} + +private class RecordingBackend : ExecutionSecretBackend { + val principals = mutableListOf() + var limit = 0 + var offset = 0 + + override suspend fun list( + principal: SecretExecutionPrincipal, + query: String?, + limit: Int, + offset: Int, + ): Result> { + principals += principal + this.limit = limit + this.offset = offset + return Result.success(emptyList()) + } + + override suspend fun get( + principal: SecretExecutionPrincipal, + secretId: String, + ): Result { + principals += principal + return Result.success(null) + } + + override suspend fun create( + principal: SecretExecutionPrincipal, + request: CreateSecretRequestData, + ): Result { + principals += principal + return Result.success("created") + } + + override suspend fun update( + principal: SecretExecutionPrincipal, + request: UpdateSecretRequestData, + ): Result { + principals += principal + return Result.success(Unit) + } + + override suspend fun delete( + principal: SecretExecutionPrincipal, + secretId: String, + ): Result { + principals += principal + return Result.success(Unit) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56dd6b0454..c753b2d4bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -172,7 +172,11 @@ intellij-platform = "243.21565.199" # bump-pushes before building, so api main's version IS the version already released and the next # merge cuts version + 1 - re-read it when #44 actually merges, because anything landing first # moves this and the topofmind pin again. -boss-plugin-api = "1.0.93" +# +# 1.0.94 adds the host-implemented execution-principal secret contract. Plugins and exact MCP +# tools receive scoped metadata/list/get/create operations; only trusted Secret Manager receives +# the human grant manager. The API PR must merge and publish before this host PR can pass CI. +boss-plugin-api = "1.0.94" [libraries] kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } @@ -270,4 +274,4 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = jxbrowser = { id = "com.teamdev.jxbrowser", version.ref = "jxbrowser-gradle-plugin" } mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } protobuf = { id = "com.google.protobuf", version.ref = "protobuf-gradle-plugin" } -graalvmNative = { id = "org.graalvm.buildtools.native", version.ref = "graalvm-native-plugin" } \ No newline at end of file +graalvmNative = { id = "org.graalvm.buildtools.native", version.ref = "graalvm-native-plugin" } diff --git a/plugin-platform/plugin-api-core/build.gradle.kts b/plugin-platform/plugin-api-core/build.gradle.kts index 285ca5829d..b4d8483c3a 100644 --- a/plugin-platform/plugin-api-core/build.gradle.kts +++ b/plugin-platform/plugin-api-core/build.gradle.kts @@ -39,6 +39,8 @@ val fetchApiPluginJar = val targetPath = apiPluginJar.get().asFile.absolutePath val siblingPaths = listOf( + // Flat local workspace layout: BossProjects/BossConsole-* + BossProjects/boss-plugin-api + rootDir.resolve("../boss-plugin-api/build/libs/boss-plugin-api-$version.jar").absolutePath, // Main checkout layout: Boss/BossConsole + Boss/boss_plugins rootDir.resolve("../boss_plugins/boss-plugin-api/build/libs/boss-plugin-api-$version.jar").absolutePath, // Worktree layout: Boss/.worktrees/ + Boss/boss_plugins diff --git a/plugin-platform/plugin-sandbox/src/desktopMain/kotlin/ai/rever/boss/plugin/sandbox/context/SandboxedPluginContext.kt b/plugin-platform/plugin-sandbox/src/desktopMain/kotlin/ai/rever/boss/plugin/sandbox/context/SandboxedPluginContext.kt index 46ed9137e2..a700b19e14 100644 --- a/plugin-platform/plugin-sandbox/src/desktopMain/kotlin/ai/rever/boss/plugin/sandbox/context/SandboxedPluginContext.kt +++ b/plugin-platform/plugin-sandbox/src/desktopMain/kotlin/ai/rever/boss/plugin/sandbox/context/SandboxedPluginContext.kt @@ -128,6 +128,12 @@ class SandboxedPluginContext( override val secretDataProvider: SecretDataProvider? get() = delegate.secretDataProvider + override val secretAccessProvider: ai.rever.boss.plugin.api.SecretAccessProvider? + get() = delegate.secretAccessProvider + + override val secretGrantManager: ai.rever.boss.plugin.api.SecretGrantManager? + get() = delegate.secretGrantManager + override val llmProvider: LlmProvider? get() = delegate.llmProvider diff --git a/supabase/migrations/20260916000000_execution_principal_secrets.sql b/supabase/migrations/20260916000000_execution_principal_secrets.sql new file mode 100644 index 0000000000..3c5ad7dc71 --- /dev/null +++ b/supabase/migrations/20260916000000_execution_principal_secrets.sql @@ -0,0 +1,374 @@ +-- Execution-principal ownership and explicit human grants for secrets. +-- +-- Existing rows remain human-owned: both execution_owner columns are NULL. They keep every +-- browser/Secret Manager behaviour they had before this migration and are invisible to plugin, +-- tool and MCP access unless a human adds one row to secret_execution_grants. + +ALTER TABLE public.secrets + ADD COLUMN IF NOT EXISTS execution_owner_type text, + ADD COLUMN IF NOT EXISTS execution_owner_id text; + +ALTER TABLE public.secrets + DROP CONSTRAINT IF EXISTS secrets_execution_owner_pair; +ALTER TABLE public.secrets + ADD CONSTRAINT secrets_execution_owner_pair CHECK ( + (execution_owner_type IS NULL AND execution_owner_id IS NULL) + OR + (execution_owner_type IS NOT NULL AND execution_owner_id IS NOT NULL + AND length(execution_owner_type) BETWEEN 1 AND 64 + AND length(execution_owner_id) BETWEEN 1 AND 500) + ); + +CREATE INDEX IF NOT EXISTS idx_secrets_execution_owner + ON public.secrets (execution_owner_type, execution_owner_id) + WHERE execution_owner_type IS NOT NULL; + +COMMENT ON COLUMN public.secrets.execution_owner_type IS + 'Host-derived execution principal kind. NULL means a human-owned legacy/general vault secret.'; +COMMENT ON COLUMN public.secrets.execution_owner_id IS + 'Host-derived execution principal id. Never accepted from an untrusted plugin UI.'; + +CREATE TABLE IF NOT EXISTS public.secret_execution_grants ( + secret_id uuid NOT NULL REFERENCES public.secrets(id) ON DELETE CASCADE, + principal_type text NOT NULL CHECK (length(principal_type) BETWEEN 1 AND 64), + principal_id text NOT NULL CHECK (length(principal_id) BETWEEN 1 AND 500), + granted_by uuid NOT NULL REFERENCES auth.users(id), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (secret_id, principal_type, principal_id) +); + +ALTER TABLE public.secret_execution_grants ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE public.secret_execution_grants FROM PUBLIC, anon, authenticated; +GRANT ALL ON TABLE public.secret_execution_grants TO service_role; + +COMMENT ON TABLE public.secret_execution_grants IS + 'Explicit human grants from one secret to one host-known plugin/tool principal. Access is only through SECURITY DEFINER RPCs.'; + +-- One policy predicate used by every scoped read. Human vault authorization remains a separate +-- requirement, so the same globally stable plugin id cannot cross from one user/account to another. +CREATE OR REPLACE FUNCTION public.execution_principal_can_use_secret( + p_secret_id uuid, + p_principal_type text, + p_principal_id text +) RETURNS boolean +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path TO '' +AS $$ + SELECT auth.uid() IS NOT NULL + AND public.can_access_secret(p_secret_id) + AND EXISTS ( + SELECT 1 + FROM public.secrets s + WHERE s.id = p_secret_id + AND ( + (s.execution_owner_type = p_principal_type + AND s.execution_owner_id = p_principal_id) + OR EXISTS ( + SELECT 1 + FROM public.secret_execution_grants g + WHERE g.secret_id = s.id + AND g.principal_type = p_principal_type + AND g.principal_id = p_principal_id + ) + ) + ); +$$; + +REVOKE EXECUTE ON FUNCTION public.execution_principal_can_use_secret(uuid,text,text) + FROM PUBLIC, anon, authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.execution_principal_can_use_secret(uuid,text,text) TO postgres; + +CREATE OR REPLACE FUNCTION public.list_execution_secrets( + p_principal_type text, + p_principal_id text, + p_query text DEFAULT NULL, + p_limit integer DEFAULT 50, + p_offset integer DEFAULT 0 +) RETURNS TABLE( + id uuid, + website text, + username text, + expiration_date timestamptz, + tags jsonb, + created_at timestamptz, + updated_at timestamptz, + access_level text +) +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path TO '' +AS $$ +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'Not authenticated' USING ERRCODE = '42501'; + END IF; + IF coalesce(length(p_principal_type), 0) = 0 OR coalesce(length(p_principal_id), 0) = 0 THEN + RAISE EXCEPTION 'Execution principal is required' USING ERRCODE = '22023'; + END IF; + + RETURN QUERY + SELECT + s.id, + s.website, + s.username, + s.expiration_date, + COALESCE((SELECT jsonb_agg(st.tag ORDER BY st.tag) + FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb), + s.created_at, + s.updated_at, + CASE + WHEN s.execution_owner_type = p_principal_type + AND s.execution_owner_id = p_principal_id THEN 'owner'::text + ELSE 'use'::text + END + FROM public.secrets s + WHERE public.execution_principal_can_use_secret(s.id, p_principal_type, p_principal_id) + AND (p_query IS NULL OR p_query = '' + OR s.website ILIKE '%' || p_query || '%' + OR s.username ILIKE '%' || p_query || '%') + ORDER BY s.created_at DESC, s.id DESC + LIMIT greatest(0, least(p_limit, 500)) OFFSET greatest(0, p_offset); +END; +$$; + +CREATE OR REPLACE FUNCTION public.get_execution_secret( + p_principal_type text, + p_principal_id text, + p_secret_id uuid +) RETURNS TABLE( + id uuid, website text, username text, password text, notes text, + expiration_date timestamptz, tags jsonb, metadata jsonb, + created_at timestamptz, updated_at timestamptz +) +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path TO '' +AS $$ +BEGIN + IF NOT public.execution_principal_can_use_secret( + p_secret_id, p_principal_type, p_principal_id + ) THEN + -- Missing and unauthorized deliberately have the same empty result. + RETURN; + END IF; + + RETURN QUERY + SELECT + s.id, s.website, s.username, + COALESCE(public.try_decrypt_text(s.password_encrypted), ''), + s.notes, s.expiration_date, + COALESCE((SELECT jsonb_agg(st.tag ORDER BY st.tag) + FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb), + COALESCE(( + SELECT jsonb_build_object( + 'twofa_enabled', sm.twofa_enabled, + 'twofa_type', sm.twofa_type, + 'twofa_secret', public.safe_decrypt_twofa_secret(sm.twofa_secret), + 'recovery_codes', public.safe_decrypt_recovery_codes(sm.recovery_codes_encrypted) + ) + FROM public.secret_metadata sm WHERE sm.secret_id = s.id + ), '{}'::jsonb), + s.created_at, s.updated_at + FROM public.secrets s + WHERE s.id = p_secret_id; +END; +$$; + +CREATE OR REPLACE FUNCTION public.create_execution_secret( + p_principal_type text, + p_principal_id text, + p_website text, + p_username text, + p_password text, + p_notes text DEFAULT NULL, + p_expiration_date timestamptz DEFAULT NULL, + p_tags text[] DEFAULT NULL, + p_twofa_enabled boolean DEFAULT false, + p_twofa_type text DEFAULT NULL, + p_recovery_codes text[] DEFAULT NULL +) RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER +SET search_path TO '' +AS $$ +DECLARE + result jsonb; + created_id uuid; +BEGIN + IF auth.uid() IS NULL THEN + RETURN jsonb_build_object('success', false, 'error', 'Not authenticated'); + END IF; + IF coalesce(length(p_principal_type), 0) = 0 OR coalesce(length(p_principal_id), 0) = 0 THEN + RETURN jsonb_build_object('success', false, 'error', 'Execution principal is required'); + END IF; + + result := public.create_secret( + p_website, p_username, p_password, p_notes, p_expiration_date, p_tags, + p_twofa_enabled, p_twofa_type, p_recovery_codes, NULL + ); + IF COALESCE((result->>'success')::boolean, false) = false THEN + RETURN result; + END IF; + + created_id := (result->>'secret_id')::uuid; + UPDATE public.secrets + SET execution_owner_type = p_principal_type, + execution_owner_id = p_principal_id + WHERE id = created_id AND user_id = auth.uid(); + + RETURN result; +END; +$$; + +CREATE OR REPLACE FUNCTION public.update_execution_secret( + p_principal_type text, + p_principal_id text, + p_secret_id uuid, + p_website text, + p_username text, + p_password text, + p_notes text DEFAULT NULL, + p_expiration_date timestamptz DEFAULT NULL, + p_tags text[] DEFAULT NULL, + p_twofa_enabled boolean DEFAULT false, + p_twofa_type text DEFAULT NULL, + p_recovery_codes text[] DEFAULT NULL +) RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER +SET search_path TO '' +AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM public.secrets s + WHERE s.id = p_secret_id + AND s.execution_owner_type = p_principal_type + AND s.execution_owner_id = p_principal_id + AND public.can_access_secret(s.id) + ) THEN + RETURN jsonb_build_object('success', false, 'error', 'Secret not found'); + END IF; + + RETURN public.update_secret( + p_secret_id, p_website, p_username, p_password, p_notes, p_expiration_date, + p_tags, p_twofa_enabled, p_twofa_type, p_recovery_codes, NULL, false + ); +END; +$$; + +CREATE OR REPLACE FUNCTION public.delete_execution_secret( + p_principal_type text, + p_principal_id text, + p_secret_id uuid +) RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER +SET search_path TO '' +AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM public.secrets s + WHERE s.id = p_secret_id + AND s.execution_owner_type = p_principal_type + AND s.execution_owner_id = p_principal_id + AND public.can_access_secret(s.id) + ) THEN + RETURN jsonb_build_object('success', false, 'error', 'Secret not found'); + END IF; + RETURN public.delete_secret(p_secret_id); +END; +$$; + +CREATE OR REPLACE FUNCTION public.list_secret_execution_grants(p_secret_id uuid) +RETURNS TABLE( + principal_type text, + principal_id text, + granted_at timestamptz, + granted_by_user_id uuid, + access_level text +) +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path TO '' +AS $$ +BEGIN + IF NOT public.can_manage_secret(p_secret_id) THEN + RAISE EXCEPTION 'Secret not found' USING ERRCODE = '42501'; + END IF; + RETURN QUERY + SELECT s.execution_owner_type, s.execution_owner_id, s.created_at, s.user_id, 'owner'::text + FROM public.secrets s + WHERE s.id = p_secret_id + AND s.execution_owner_type IS NOT NULL + UNION ALL + SELECT g.principal_type, g.principal_id, g.created_at, g.granted_by, 'use'::text + FROM public.secret_execution_grants g + WHERE g.secret_id = p_secret_id + AND NOT EXISTS ( + SELECT 1 FROM public.secrets s + WHERE s.id = g.secret_id + AND s.execution_owner_type = g.principal_type + AND s.execution_owner_id = g.principal_id + ) + ORDER BY 3, 1, 2; +END; +$$; + +CREATE OR REPLACE FUNCTION public.grant_secret_to_execution_principal( + p_secret_id uuid, + p_principal_type text, + p_principal_id text +) RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER +SET search_path TO '' +AS $$ +BEGIN + IF NOT public.can_manage_secret(p_secret_id) THEN + RETURN jsonb_build_object('success', false, 'error', 'Secret not found'); + END IF; + IF coalesce(length(p_principal_type), 0) = 0 OR coalesce(length(p_principal_id), 0) = 0 THEN + RETURN jsonb_build_object('success', false, 'error', 'Execution principal is required'); + END IF; + + INSERT INTO public.secret_execution_grants( + secret_id, principal_type, principal_id, granted_by + ) VALUES ( + p_secret_id, p_principal_type, p_principal_id, auth.uid() + ) + ON CONFLICT (secret_id, principal_type, principal_id) + DO UPDATE SET granted_by = EXCLUDED.granted_by, created_at = now(); + RETURN jsonb_build_object('success', true); +END; +$$; + +CREATE OR REPLACE FUNCTION public.revoke_secret_from_execution_principal( + p_secret_id uuid, + p_principal_type text, + p_principal_id text +) RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER +SET search_path TO '' +AS $$ +BEGIN + IF NOT public.can_manage_secret(p_secret_id) THEN + RETURN jsonb_build_object('success', false, 'error', 'Secret not found'); + END IF; + DELETE FROM public.secret_execution_grants + WHERE secret_id = p_secret_id + AND principal_type = p_principal_type + AND principal_id = p_principal_id; + RETURN jsonb_build_object('success', true); +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.list_execution_secrets(text,text,text,integer,integer) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.get_execution_secret(text,text,uuid) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.create_execution_secret(text,text,text,text,text,text,timestamptz,text[],boolean,text,text[]) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.update_execution_secret(text,text,uuid,text,text,text,text,timestamptz,text[],boolean,text,text[]) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.delete_execution_secret(text,text,uuid) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.list_secret_execution_grants(uuid) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.grant_secret_to_execution_principal(uuid,text,text) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.revoke_secret_from_execution_principal(uuid,text,text) FROM PUBLIC, anon; + +GRANT EXECUTE ON FUNCTION public.list_execution_secrets(text,text,text,integer,integer) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.get_execution_secret(text,text,uuid) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.create_execution_secret(text,text,text,text,text,text,timestamptz,text[],boolean,text,text[]) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.update_execution_secret(text,text,uuid,text,text,text,text,timestamptz,text[],boolean,text,text[]) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.delete_execution_secret(text,text,uuid) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.list_secret_execution_grants(uuid) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.grant_secret_to_execution_principal(uuid,text,text) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.revoke_secret_from_execution_principal(uuid,text,text) TO authenticated, service_role; diff --git a/supabase/tests/execution_principal_secrets_test.sql b/supabase/tests/execution_principal_secrets_test.sql new file mode 100644 index 0000000000..85fc981a78 --- /dev/null +++ b/supabase/tests/execution_principal_secrets_test.sql @@ -0,0 +1,140 @@ +-- pgTAP coverage for host-bound plugin/tool ownership and explicit human grants. +BEGIN; +SELECT no_plan(); + +DO $fixture$ +DECLARE existing uuid; +BEGIN + SELECT id INTO existing FROM vault.secrets WHERE name = 'master_encryption_key'; + IF existing IS NULL THEN + PERFORM vault.create_secret('execution-principal-test-key-32-bytes', 'master_encryption_key', 'pgTAP only'); + ELSE + PERFORM vault.update_secret(existing, 'execution-principal-test-key-32-bytes', 'master_encryption_key', 'pgTAP only'); + END IF; +END; +$fixture$; + +INSERT INTO auth.users (id, email) VALUES + ('e1600000-0000-4000-8000-000000000001', 'execution-owner@pgtap.test'), + ('e1600000-0000-4000-8000-000000000002', 'other-owner@pgtap.test'); + +INSERT INTO public.secrets( + id, user_id, website, username, password_encrypted, execution_owner_type, execution_owner_id +) VALUES + ('e1600000-0000-4000-8000-000000000011', 'e1600000-0000-4000-8000-000000000001', + 'human.example', 'human', public.encrypt_text('human-value'), NULL, NULL), + ('e1600000-0000-4000-8000-000000000012', 'e1600000-0000-4000-8000-000000000001', + 'plugin.example', 'plugin', public.encrypt_text('plugin-value'), 'plugin', 'plugin-a'), + ('e1600000-0000-4000-8000-000000000013', 'e1600000-0000-4000-8000-000000000002', + 'other-user.example', 'plugin', public.encrypt_text('other-value'), 'plugin', 'plugin-a'); + +SELECT set_config( + 'request.jwt.claims', + '{"sub":"e1600000-0000-4000-8000-000000000001","role":"authenticated"}', + true +); +SET LOCAL ROLE authenticated; + +SELECT is( + (SELECT count(*)::integer FROM public.list_execution_secrets('plugin', 'plugin-a')), + 1, + 'the owning plugin sees its own secret without crossing into another user account' +); +SELECT is( + (SELECT count(*)::integer FROM public.list_execution_secrets('plugin', 'plugin-b')), + 0, + 'another plugin sees neither the ownerless human secret nor plugin-a secret' +); +SELECT is( + (SELECT password FROM public.get_execution_secret( + 'plugin', 'plugin-a', 'e1600000-0000-4000-8000-000000000012')), + 'plugin-value', + 'the owning principal can explicitly resolve plaintext' +); +SELECT is( + (SELECT access_level FROM public.list_secret_execution_grants( + 'e1600000-0000-4000-8000-000000000012')), + 'owner', + 'the human grant view identifies immutable creator ownership' +); +SELECT is( + (SELECT count(*)::integer FROM public.get_execution_secret( + 'plugin', 'plugin-b', 'e1600000-0000-4000-8000-000000000012')), + 0, + 'missing and unauthorized get calls are indistinguishable empty results' +); + +SELECT is( + (public.grant_secret_to_execution_principal( + 'e1600000-0000-4000-8000-000000000011', 'plugin', 'plugin-b')->>'success'), + 'true', + 'a human manager may grant one ownerless secret to one plugin' +); +SELECT is( + (SELECT access_level FROM public.list_execution_secrets('plugin', 'plugin-b')), + 'use', + 'an explicit recipient sees the granted secret with use access' +); +SELECT is( + (SELECT password FROM public.get_execution_secret( + 'plugin', 'plugin-b', 'e1600000-0000-4000-8000-000000000011')), + 'human-value', + 'a use grant permits an explicit plaintext read' +); +SELECT is( + (public.update_execution_secret( + 'plugin', 'plugin-b', 'e1600000-0000-4000-8000-000000000011', + 'human.example', 'human', 'changed')->>'success'), + 'false', + 'a use grant does not permit mutation' +); +SELECT is( + (public.delete_execution_secret( + 'plugin', 'plugin-b', 'e1600000-0000-4000-8000-000000000011')->>'success'), + 'false', + 'a use grant does not permit deletion' +); + +SELECT is( + (public.revoke_secret_from_execution_principal( + 'e1600000-0000-4000-8000-000000000011', 'plugin', 'plugin-b')->>'success'), + 'true', + 'a human manager may revoke a grant' +); +SELECT is( + (SELECT count(*)::integer FROM public.get_execution_secret( + 'plugin', 'plugin-b', 'e1600000-0000-4000-8000-000000000011')), + 0, + 'revocation takes effect on the next read' +); + +SELECT is( + (public.create_execution_secret( + 'mcp_tool', 'secret-manager/secret_create', 'tool.example', 'tool', 'tool-value')->>'success'), + 'true', + 'a tool creates a secret through the scoped RPC' +); +SELECT is( + (SELECT execution_owner_id FROM public.secrets WHERE website = 'tool.example'), + 'secret-manager/secret_create', + 'the created row records the supplied host principal identity' +); +SELECT is( + (SELECT count(*)::integer FROM public.list_execution_secrets( + 'mcp_tool', 'secret-manager/secret_get') WHERE website = 'tool.example'), + 0, + 'another tool in the same plugin does not inherit the creating tool ownership' +); + +RESET ROLE; +SELECT ok( + NOT has_table_privilege('authenticated', 'public.secret_execution_grants', 'SELECT'), + 'authenticated clients cannot enumerate the grant table directly' +); +SELECT ok( + NOT has_function_privilege('anon', 'public.get_execution_secret(text,text,uuid)', 'EXECUTE'), + 'anonymous clients cannot call scoped plaintext retrieval' +); + +SELECT * FROM finish(); +ROLLBACK;