From e6c13d2f1ce453563cd9322669bd29a202599d7d Mon Sep 17 00:00:00 2001 From: Manaswini Ragamouni Date: Thu, 19 Mar 2026 18:26:32 +0000 Subject: [PATCH] Add workspace isolation infrastructure for multi-tenant support - WorkspaceContext: immutable value object holding workspaceId and tenantId - TenancyContextExtractor: extracts workspace/tenant context from ThreadContext, no-op when isolation disabled - WorkspaceFilter: search filter injection, ownership verification, and workspace_id immutability guard. All methods are no-ops in plugin mode. - AlertingSettings: added WORKSPACE_ISOLATION_ENABLED setting (default false) - AlertingPlugin: registered new setting in getSettings() Signed-off-by: Manaswini Ragamouni --- .../org/opensearch/alerting/AlertingPlugin.kt | 3 +- .../alerting/settings/AlertingSettings.kt | 6 + .../workspace/TenancyContextExtractor.kt | 65 +++++++++++ .../alerting/workspace/WorkspaceContext.kt | 25 ++++ .../alerting/workspace/WorkspaceFilter.kt | 107 ++++++++++++++++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 alerting/src/main/kotlin/org/opensearch/alerting/workspace/TenancyContextExtractor.kt create mode 100644 alerting/src/main/kotlin/org/opensearch/alerting/workspace/WorkspaceContext.kt create mode 100644 alerting/src/main/kotlin/org/opensearch/alerting/workspace/WorkspaceFilter.kt diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/AlertingPlugin.kt b/alerting/src/main/kotlin/org/opensearch/alerting/AlertingPlugin.kt index a2a01e645..9db3a2999 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/AlertingPlugin.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/AlertingPlugin.kt @@ -433,7 +433,8 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R AlertingSettings.COMMENTS_MAX_CONTENT_SIZE, AlertingSettings.MAX_COMMENTS_PER_ALERT, AlertingSettings.MAX_COMMENTS_PER_NOTIFICATION, - AlertingSettings.NOTIFICATION_CONTEXT_RESULTS_ALLOWED_ROLES + AlertingSettings.NOTIFICATION_CONTEXT_RESULTS_ALLOWED_ROLES, + AlertingSettings.WORKSPACE_ISOLATION_ENABLED ) } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/settings/AlertingSettings.kt b/alerting/src/main/kotlin/org/opensearch/alerting/settings/AlertingSettings.kt index 2659ae74c..2fb3f39ba 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/settings/AlertingSettings.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/settings/AlertingSettings.kt @@ -311,5 +311,11 @@ class AlertingSettings { Setting.Property.NodeScope, Setting.Property.Dynamic ) + + val WORKSPACE_ISOLATION_ENABLED = Setting.boolSetting( + "plugins.alerting.workspace_isolation_enabled", + false, + Setting.Property.NodeScope, Setting.Property.Dynamic + ) } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/workspace/TenancyContextExtractor.kt b/alerting/src/main/kotlin/org/opensearch/alerting/workspace/TenancyContextExtractor.kt new file mode 100644 index 000000000..d0bb50c9f --- /dev/null +++ b/alerting/src/main/kotlin/org/opensearch/alerting/workspace/TenancyContextExtractor.kt @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.alerting.workspace + +import org.apache.logging.log4j.LogManager +import org.opensearch.OpenSearchStatusException +import org.opensearch.commons.alerting.util.AlertingException +import org.opensearch.core.rest.RestStatus +import org.opensearch.threadpool.ThreadPool + +/** + * Extracts workspace/tenancy information from the OpenSearch ThreadContext + * headers propagated by the hosting runtime. + * + * Returns null when workspace isolation is disabled (backward compatibility). + * Throws BAD_REQUEST when isolation is enabled but required headers are missing + * (missing headers indicate an infrastructure/wiring problem, not a permissions issue). + */ +class TenancyContextExtractor( + private val threadPool: ThreadPool, + private val isWorkspaceIsolationEnabled: () -> Boolean +) { + + companion object { + private val log = LogManager.getLogger(TenancyContextExtractor::class.java) + } + + /** + * Extract WorkspaceContext from the current thread context. + * @return WorkspaceContext if workspace isolation is enabled and headers are present, null otherwise. + * @throws AlertingException with BAD_REQUEST status if isolation is enabled but required headers are missing. + */ + fun extract(): WorkspaceContext? { + if (!isWorkspaceIsolationEnabled()) { + return null + } + + val threadContext = threadPool.threadContext + + val workspaceId = threadContext.getTransient(WorkspaceContext.HEADER_WORKSPACE_ID) + val tenantId = threadContext.getTransient(WorkspaceContext.HEADER_TENANT_ID) + + if (workspaceId.isNullOrBlank() || tenantId.isNullOrBlank()) { + log.error( + "Workspace isolation is enabled but required headers are missing. " + + "workspace_id=${workspaceId.isNullOrBlank()}, tenant_id=${tenantId.isNullOrBlank()}" + ) + throw AlertingException.wrap( + OpenSearchStatusException( + "Missing required tenancy context. Ensure workspace_id and tenant_id are propagated.", + RestStatus.BAD_REQUEST + ) + ) + } + + log.debug("Extracted workspace context: workspaceId=$workspaceId, tenantId=$tenantId") + return WorkspaceContext( + workspaceId = workspaceId, + tenantId = tenantId + ) + } +} diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/workspace/WorkspaceContext.kt b/alerting/src/main/kotlin/org/opensearch/alerting/workspace/WorkspaceContext.kt new file mode 100644 index 000000000..40c2cffbd --- /dev/null +++ b/alerting/src/main/kotlin/org/opensearch/alerting/workspace/WorkspaceContext.kt @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.alerting.workspace + +/** + * Immutable value object carrying tenancy information extracted from the + * request context. Propagated through the entire request lifecycle. + * + * @property workspaceId Workspace identifier for document-level isolation within a tenant. + * Used by the plugin to filter reads and stamp writes. + * @property tenantId Tenant identifier for storage-level isolation. + * Passed to the Remote Metadata SDK on every persistence operation. + */ +data class WorkspaceContext( + val workspaceId: String, + val tenantId: String +) { + companion object { + const val HEADER_WORKSPACE_ID = "_workspace_id" + const val HEADER_TENANT_ID = "_tenant_id" + } +} diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/workspace/WorkspaceFilter.kt b/alerting/src/main/kotlin/org/opensearch/alerting/workspace/WorkspaceFilter.kt new file mode 100644 index 000000000..ba4ce58b7 --- /dev/null +++ b/alerting/src/main/kotlin/org/opensearch/alerting/workspace/WorkspaceFilter.kt @@ -0,0 +1,107 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.alerting.workspace + +import org.apache.logging.log4j.LogManager +import org.opensearch.OpenSearchStatusException +import org.opensearch.commons.alerting.util.AlertingException +import org.opensearch.core.rest.RestStatus +import org.opensearch.index.query.QueryBuilders +import org.opensearch.search.builder.SearchSourceBuilder + +/** + * Implements workspace isolation patterns for alerting CRUD and search operations. + * + * Four tenancy patterns: + * 1. Stamp — inject workspace_id into a document at create time + * 2. Filter — inject workspace_id term filter into search queries + * 3. Ownership Check — verify a fetched document belongs to the requesting workspace + * 4. App-Scoped — filter by app_arn for shared resources (destinations, channels) + * + * All methods are no-ops when WorkspaceContext is null (backward compatibility). + */ +object WorkspaceFilter { + + const val WORKSPACE_ID_FIELD = "workspace_id" + + private val log = LogManager.getLogger(WorkspaceFilter::class.java) + + /** + * Filter Pattern: Inject a workspace_id term filter into a search query. + * The original query semantics are preserved — moved into a bool.must clause, + * with the workspace filter in bool.filter (does not affect scoring). + * + * No-op when context is null. + */ + fun applySearchFilter( + searchSourceBuilder: SearchSourceBuilder, + context: WorkspaceContext? + ): SearchSourceBuilder { + if (context == null) return searchSourceBuilder + + val workspaceTermFilter = QueryBuilders.termQuery(WORKSPACE_ID_FIELD, context.workspaceId) + val existingQuery = searchSourceBuilder.query() + + val filteredQuery = if (existingQuery != null) { + QueryBuilders.boolQuery() + .must(existingQuery) + .filter(workspaceTermFilter) + } else { + QueryBuilders.boolQuery() + .filter(workspaceTermFilter) + } + + searchSourceBuilder.query(filteredQuery) + log.debug("Applied workspace filter for workspaceId=${context.workspaceId}") + return searchSourceBuilder + } + + /** + * Ownership Check: Verify that a fetched document's workspace_id matches + * the requesting workspace. Returns true if they match. + * + * Returns false if the document has no workspace_id (legacy document). + * Always returns true when context is null (isolation disabled). + */ + fun verifyOwnership(documentWorkspaceId: String?, context: WorkspaceContext?): Boolean { + if (context == null) return true + if (documentWorkspaceId == null) return false + return documentWorkspaceId == context.workspaceId + } + + /** + * Throws NOT_FOUND if ownership check fails. Uses NOT_FOUND instead of + * FORBIDDEN to prevent information leakage about resource existence. + */ + fun requireOwnership(documentWorkspaceId: String?, context: WorkspaceContext?, resourceType: String, resourceId: String) { + if (!verifyOwnership(documentWorkspaceId, context)) { + throw AlertingException.wrap( + OpenSearchStatusException( + "$resourceType $resourceId not found", + RestStatus.NOT_FOUND + ) + ) + } + } + + /** + * Validates that a workspace_id is not being changed on update. + * workspace_id is immutable once stamped at creation. + * + * No-op when context is null. + */ + fun validateWorkspaceIdImmutable(existingWorkspaceId: String?, context: WorkspaceContext?) { + if (context == null) return + if (existingWorkspaceId != null && existingWorkspaceId != context.workspaceId) { + throw AlertingException.wrap( + OpenSearchStatusException( + "Cannot change workspace_id of existing resource", + RestStatus.FORBIDDEN + ) + ) + } + } +}