Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Original file line number Diff line number Diff line change
@@ -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<String>(WorkspaceContext.HEADER_WORKSPACE_ID)
val tenantId = threadContext.getTransient<String>(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
)
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
)
)
}
}
}
Loading