diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml
index a861a97df357c..88ff7d2fe1414 100644
--- a/oas_docs/output/kibana.serverless.yaml
+++ b/oas_docs/output/kibana.serverless.yaml
@@ -44039,6 +44039,143 @@ paths:
x-metaTags:
- content: Kibana, Elastic Cloud Serverless
name: product_name
+ /api/fleet/epm/packages/_bulk_namespace_customization:
+ post:
+ description: |-
+ **Spaces method and path for this operation:**
+
+
post /s/{space_id}/api/fleet/epm/packages/_bulk_namespace_customization
+
+ Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information.
+
+ Enable or disable namespace-level index template customization for a list of packages in one call. Use this for IaC-style declarative flows.
[Required authorization] Route required privileges: integrations-all AND fleet-agent-policies-all.
+ operationId: post-fleet-epm-packages-bulk-namespace-customization
+ parameters:
+ - description: A required header to protect against CSRF attacks
+ in: header
+ name: kbn-xsrf
+ required: true
+ schema:
+ example: 'true'
+ type: string
+ requestBody:
+ content:
+ application/json:
+ examples:
+ bulkNamespaceCustomizationRequest:
+ value:
+ disable:
+ - dev
+ enable:
+ - production
+ - staging
+ packages:
+ - system
+ - nginx
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ disable:
+ description: Namespaces to disable namespace-level customization for on each package.
+ items:
+ type: string
+ maxItems: 100
+ type: array
+ enable:
+ description: Namespaces to enable namespace-level customization for on each package.
+ items:
+ type: string
+ maxItems: 100
+ type: array
+ packages:
+ description: Package names to apply the customization changes to.
+ items:
+ type: string
+ maxItems: 1000
+ minItems: 1
+ type: array
+ required:
+ - packages
+ responses:
+ '200':
+ content:
+ application/json:
+ examples:
+ successResponse:
+ value:
+ items:
+ - name: system
+ namespace_customization_enabled_for:
+ - production
+ - staging
+ success: true
+ - error: Package nginx is not installed
+ name: nginx
+ success: false
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ items:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ error:
+ type: string
+ name:
+ type: string
+ namespace_customization_enabled_for:
+ description: 'The opt-in list on the package. Returned whenever the package is installed: the new list on success, or the unchanged list when the request is rejected (for example, because of a namespace-prefix restriction).'
+ items:
+ type: string
+ maxItems: 100
+ type: array
+ success:
+ type: boolean
+ required:
+ - name
+ - success
+ maxItems: 1000
+ type: array
+ required:
+ - items
+ description: 'OK: A successful request.'
+ '400':
+ content:
+ application/json:
+ examples:
+ badRequestResponse:
+ value:
+ error: Bad Request
+ message: 'Namespaces must not appear in both enable and disable: production'
+ statusCode: 400
+ schema:
+ additionalProperties: false
+ description: Generic Error
+ type: object
+ properties:
+ attributes:
+ nullable: true
+ error:
+ type: string
+ errorType:
+ type: string
+ message:
+ type: string
+ statusCode:
+ type: number
+ required:
+ - message
+ - attributes
+ description: A bad request.
+ summary: Bulk enable/disable namespace-level customization for packages
+ tags:
+ - Elastic Package Manager (EPM)
+ x-metaTags:
+ - content: Kibana, Elastic Cloud Serverless
+ name: product_name
/api/fleet/epm/packages/_bulk_rollback:
post:
description: |-
@@ -45669,6 +45806,12 @@ paths:
content:
application/json:
examples:
+ putUpdatePackageNamespaceCustomizationExample:
+ description: Enable namespace-level customization for the `production` and `staging` namespaces
+ value:
+ namespace_customization_enabled_for:
+ - production
+ - staging
putUpdatePackageRequestExample:
description: Update keep_policies_up_to_date setting for a package
value:
@@ -45679,8 +45822,12 @@ paths:
properties:
keepPoliciesUpToDate:
type: boolean
- required:
- - keepPoliciesUpToDate
+ namespace_customization_enabled_for:
+ description: Namespaces for which namespace-level customization is enabled on this package.
+ items:
+ type: string
+ maxItems: 100
+ type: array
responses:
'200':
content:
@@ -47300,6 +47447,12 @@ paths:
content:
application/json:
examples:
+ putUpdatePackageNamespaceCustomizationExample:
+ description: Enable namespace-level customization for the `production` and `staging` namespaces
+ value:
+ namespace_customization_enabled_for:
+ - production
+ - staging
putUpdatePackageRequestExample:
description: Update keep_policies_up_to_date setting for a package
value:
@@ -47310,8 +47463,12 @@ paths:
properties:
keepPoliciesUpToDate:
type: boolean
- required:
- - keepPoliciesUpToDate
+ namespace_customization_enabled_for:
+ description: Namespaces for which namespace-level customization is enabled on this package.
+ items:
+ type: string
+ maxItems: 100
+ type: array
responses:
'200':
content:
diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml
index f40d70a21c56c..06f63322fb703 100644
--- a/oas_docs/output/kibana.yaml
+++ b/oas_docs/output/kibana.yaml
@@ -47168,6 +47168,143 @@ paths:
x-metaTags:
- content: Kibana
name: product_name
+ /api/fleet/epm/packages/_bulk_namespace_customization:
+ post:
+ description: |-
+ **Spaces method and path for this operation:**
+
+ post /s/{space_id}/api/fleet/epm/packages/_bulk_namespace_customization
+
+ Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information.
+
+ Enable or disable namespace-level index template customization for a list of packages in one call. Use this for IaC-style declarative flows.
[Required authorization] Route required privileges: integrations-all AND fleet-agent-policies-all.
+ operationId: post-fleet-epm-packages-bulk-namespace-customization
+ parameters:
+ - description: A required header to protect against CSRF attacks
+ in: header
+ name: kbn-xsrf
+ required: true
+ schema:
+ example: 'true'
+ type: string
+ requestBody:
+ content:
+ application/json:
+ examples:
+ bulkNamespaceCustomizationRequest:
+ value:
+ disable:
+ - dev
+ enable:
+ - production
+ - staging
+ packages:
+ - system
+ - nginx
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ disable:
+ description: Namespaces to disable namespace-level customization for on each package.
+ items:
+ type: string
+ maxItems: 100
+ type: array
+ enable:
+ description: Namespaces to enable namespace-level customization for on each package.
+ items:
+ type: string
+ maxItems: 100
+ type: array
+ packages:
+ description: Package names to apply the customization changes to.
+ items:
+ type: string
+ maxItems: 1000
+ minItems: 1
+ type: array
+ required:
+ - packages
+ responses:
+ '200':
+ content:
+ application/json:
+ examples:
+ successResponse:
+ value:
+ items:
+ - name: system
+ namespace_customization_enabled_for:
+ - production
+ - staging
+ success: true
+ - error: Package nginx is not installed
+ name: nginx
+ success: false
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ items:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ error:
+ type: string
+ name:
+ type: string
+ namespace_customization_enabled_for:
+ description: 'The opt-in list on the package. Returned whenever the package is installed: the new list on success, or the unchanged list when the request is rejected (for example, because of a namespace-prefix restriction).'
+ items:
+ type: string
+ maxItems: 100
+ type: array
+ success:
+ type: boolean
+ required:
+ - name
+ - success
+ maxItems: 1000
+ type: array
+ required:
+ - items
+ description: 'OK: A successful request.'
+ '400':
+ content:
+ application/json:
+ examples:
+ badRequestResponse:
+ value:
+ error: Bad Request
+ message: 'Namespaces must not appear in both enable and disable: production'
+ statusCode: 400
+ schema:
+ additionalProperties: false
+ description: Generic Error
+ type: object
+ properties:
+ attributes:
+ nullable: true
+ error:
+ type: string
+ errorType:
+ type: string
+ message:
+ type: string
+ statusCode:
+ type: number
+ required:
+ - message
+ - attributes
+ description: A bad request.
+ summary: Bulk enable/disable namespace-level customization for packages
+ tags:
+ - Elastic Package Manager (EPM)
+ x-metaTags:
+ - content: Kibana
+ name: product_name
/api/fleet/epm/packages/_bulk_rollback:
post:
description: |-
@@ -48798,6 +48935,12 @@ paths:
content:
application/json:
examples:
+ putUpdatePackageNamespaceCustomizationExample:
+ description: Enable namespace-level customization for the `production` and `staging` namespaces
+ value:
+ namespace_customization_enabled_for:
+ - production
+ - staging
putUpdatePackageRequestExample:
description: Update keep_policies_up_to_date setting for a package
value:
@@ -48808,8 +48951,12 @@ paths:
properties:
keepPoliciesUpToDate:
type: boolean
- required:
- - keepPoliciesUpToDate
+ namespace_customization_enabled_for:
+ description: Namespaces for which namespace-level customization is enabled on this package.
+ items:
+ type: string
+ maxItems: 100
+ type: array
responses:
'200':
content:
@@ -50429,6 +50576,12 @@ paths:
content:
application/json:
examples:
+ putUpdatePackageNamespaceCustomizationExample:
+ description: Enable namespace-level customization for the `production` and `staging` namespaces
+ value:
+ namespace_customization_enabled_for:
+ - production
+ - staging
putUpdatePackageRequestExample:
description: Update keep_policies_up_to_date setting for a package
value:
@@ -50439,8 +50592,12 @@ paths:
properties:
keepPoliciesUpToDate:
type: boolean
- required:
- - keepPoliciesUpToDate
+ namespace_customization_enabled_for:
+ description: Namespaces for which namespace-level customization is enabled on this package.
+ items:
+ type: string
+ maxItems: 100
+ type: array
responses:
'200':
content:
diff --git a/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/epm-packages/10.9.0.json b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/epm-packages/10.9.0.json
index cbcb568f5e6a4..7e57aa8fe7cf1 100644
--- a/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/epm-packages/10.9.0.json
+++ b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/epm-packages/10.9.0.json
@@ -69,4 +69,4 @@
"installed_as_dependency": true
}
]
- }
\ No newline at end of file
+ }
diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts
index 10f8bc9a999c9..d3a6da86484bf 100644
--- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts
+++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts
@@ -95,7 +95,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"entity-engine-status": "005903620a00737932aa54ae57817b078810b2f71cc42e7715d1c22c5e5b715e",
"entity-store-ccs-state": "79b9cdbb27444593a2c07a4384313ea37c8399604f016e9d633b71cb9c937489",
"entity-store-global-state": "8581bc65d1b2bf6d0218b693509129a2515599aeff8933d85353a3fb28d52bda",
- "epm-packages": "b59264fcbd91ad78497a10f6cf64d0e1d319833d637dfa404619f7ccfeee3b35",
+ "epm-packages": "09698b258d4fcf5b860d1d58f5807dbec13e38b112abbb2cdd3646c6dda660a5",
"epm-packages-assets": "1095b56fabdeb3994a60f4da02e87179dfaf57d5bb23b97458129bf14c66b46e",
"event-annotation-group": "21141aa64bba4d05ee6ebe0b0d75475452bca50e73f902a38800457d0727014d",
"event_loop_delays_daily": "5f320d1628eebdcfd38969dbef3304ed62d19cdc902c20188892187cff3cbc8b",
@@ -663,7 +663,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"epm-packages|global: 9d90d41b665a6b53aa6e984ad0e100ff733e05b9",
"epm-packages|mappings: 1cbcdbf7f2f945430538f54a97fafaa76b428223",
"epm-packages|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709",
- "epm-packages|10.9.0: 9c55a1b830381dc955df321d3c27a4b73c985cacc62bdc478e68c27d85cf1ebc",
+ "epm-packages|10.9.0: 289199b5ae3c469a4f54b408967cc85b0d81b817d0ae82a3004ba5c8761e0dbb",
"epm-packages|10.8.0: 394d4e58c2cba229ddebd3ca9bf74377e9113c9072e3c8bbe9a22bd435823a77",
"epm-packages|10.7.0: ef826e5d67dfd5a82eb199e125517372b7898cd72480a49679f8444f9816aed7",
"epm-packages|10.6.0: 895e16e564980bb84eb75fad3b08a7d4b98d7b193629263c82a4030754b87955",
diff --git a/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts b/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts
index ba6efe4a12844..e45028ccec658 100644
--- a/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts
+++ b/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts
@@ -33,6 +33,7 @@ export const EPM_API_ROUTES = {
BULK_UNINSTALL_INFO_PATTERN: `${EPM_PACKAGES_MANY}/_bulk_uninstall/{taskId}`,
BULK_ROLLBACK_PATTERN: `${EPM_PACKAGES_MANY}/_bulk_rollback`,
BULK_ROLLBACK_INFO_PATTERN: `${EPM_PACKAGES_MANY}/_bulk_rollback/{taskId}`,
+ BULK_NAMESPACE_CUSTOMIZATION_PATTERN: `${EPM_PACKAGES_MANY}/_bulk_namespace_customization`,
LIST_PATTERN: EPM_PACKAGES_MANY,
INSTALLED_LIST_PATTERN: EPM_PACKAGES_INSTALLED,
LIMITED_LIST_PATTERN: `${EPM_PACKAGES_MANY}/limited`,
diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts
index 6732f4199a978..b8a5c43f1a449 100644
--- a/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts
+++ b/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts
@@ -814,6 +814,8 @@ export interface Installation {
is_dependency_of?: IsDependencyOf | null;
/** Whether the package was installed as a dependency (not manually by a user) */
installed_as_dependency?: boolean;
+ /** Namespaces opted in for namespace-level customization for this package. */
+ namespace_customization_enabled_for?: string[];
/** Snapshot of dependency version changes made when this (composable) package was last installed/upgraded; used for rollback */
previous_dependency_versions?: Array<{ name: string; previous_version: string | null }> | null;
}
diff --git a/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/epm.ts b/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/epm.ts
index f3becfff19fea..eea2b6b57f795 100644
--- a/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/epm.ts
+++ b/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/epm.ts
@@ -105,6 +105,7 @@ export interface UpdatePackageRequest {
};
body: {
keepPoliciesUpToDate?: boolean;
+ namespace_customization_enabled_for?: string[];
};
}
diff --git a/x-pack/platform/plugins/shared/fleet/server/plugin.ts b/x-pack/platform/plugins/shared/fleet/server/plugin.ts
index bc7202385ae9b..2fa18c2ef10fd 100644
--- a/x-pack/platform/plugins/shared/fleet/server/plugin.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/plugin.ts
@@ -170,6 +170,7 @@ import {
scheduleVerifyPermissionsTask,
} from './tasks/agentless/verify_permissions_task';
import { registerReindexIntegrationKnowledgeTask } from './tasks/reindex_integration_knowledge_task';
+import { registerSyncNamespaceTemplatesTask } from './tasks/sync_namespace_templates_task';
import {
type AgentlessPoliciesService,
AgentlessPoliciesServiceImpl,
@@ -709,6 +710,7 @@ export class FleetPlugin
registerVerifyPermissionsTask(deps.taskManager);
registerVerifierPolicyCleanupTask(deps.taskManager);
registerReindexIntegrationKnowledgeTask(deps.taskManager);
+ registerSyncNamespaceTemplatesTask(deps.taskManager);
registerReassignAgentsToVersionSpecificPoliciesTask(deps.taskManager);
this.bulkActionsResolver = new BulkActionsResolver(deps.taskManager, core);
diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/epm/bulk_handler.ts b/x-pack/platform/plugins/shared/fleet/server/routes/epm/bulk_handler.ts
index d6cba05339f72..108fa239516c2 100644
--- a/x-pack/platform/plugins/shared/fleet/server/routes/epm/bulk_handler.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/routes/epm/bulk_handler.ts
@@ -8,15 +8,24 @@
import type { TypeOf } from '@kbn/config-schema';
import type { KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server';
+import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
+import pMap from 'p-map';
import { appContextService, licenseService, packagePolicyService } from '../../services';
import type {
BulkRollbackPackagesRequestSchema,
+ BulkNamespaceCustomizationRequestSchema,
BulkUninstallPackagesRequestSchema,
BulkUpgradePackagesRequestSchema,
FleetRequestHandler,
GetOneBulkOperationPackagesRequestSchema,
} from '../../types';
+import { updatePackage } from '../../services/epm/packages/update';
+import { scheduleSyncNamespaceTemplatesTask } from '../../tasks/sync_namespace_templates_task';
+import {
+ getAllowedNamespacePrefixesForSpace,
+ isNamespaceAllowedByPrefixes,
+} from '../../services/spaces/policy_namespaces';
import type {
BulkOperationPackagesResponse,
@@ -180,3 +189,108 @@ export const postBulkRollbackPackagesHandler: FleetRequestHandler<
};
return response.ok({ body });
};
+
+// Number of concurrent per-package namespace-customization updates in the bulk
+// endpoint. Each one hits the Installation SO; kept small to avoid SO client contention.
+const BULK_NAMESPACE_CUSTOMIZATION_CONCURRENCY = 5;
+
+export const postBulkNamespaceCustomizationHandler: FleetRequestHandler<
+ undefined,
+ undefined,
+ TypeOf
+> = async (context, request, response) => {
+ const fleetContext = await context.fleet;
+ const savedObjectsClient = fleetContext.internalSoClient;
+ const spaceId = savedObjectsClient.getCurrentNamespace() ?? DEFAULT_SPACE_ID;
+
+ const { packages, enable = [], disable = [] } = request.body;
+
+ const conflicts = enable.filter((ns) => disable.includes(ns));
+ if (conflicts.length > 0) {
+ throw new FleetError(
+ `Namespaces must not appear in both enable and disable: ${conflicts.join(', ')}`
+ );
+ }
+
+ const taskManagerStart = getTaskManagerStart();
+ const allowedPrefixes = await getAllowedNamespacePrefixesForSpace(spaceId);
+
+ const items = await pMap(
+ packages,
+ async (packageName) => {
+ const installation = await getInstallationsByName({
+ savedObjectsClient,
+ pkgNames: [packageName],
+ });
+ if (installation.length === 0) {
+ return {
+ name: packageName,
+ success: false,
+ error: `Package ${packageName} is not installed`,
+ };
+ }
+
+ const current = installation[0].namespace_customization_enabled_for ?? [];
+ // Tracks the persisted state across the try block: starts at `current`, advances
+ // to `newList` once `updatePackage` has committed. The catch handler returns this
+ // so the response always reflects what's actually in the SO.
+ let persistedList = current;
+
+ try {
+ const afterEnable = [...new Set([...current, ...enable])];
+ const newList = afterEnable.filter((ns) => !disable.includes(ns));
+
+ // Gate both added and removed namespaces on the current space's allowed_namespace_prefixes.
+ const added = newList.filter((ns) => !current.includes(ns));
+ const removed = current.filter((ns) => !newList.includes(ns));
+ const changed = [...added, ...removed];
+ const blocked = changed.filter((ns) => !isNamespaceAllowedByPrefixes(ns, allowedPrefixes));
+ if (blocked.length > 0) {
+ return {
+ name: packageName,
+ success: false,
+ namespace_customization_enabled_for: current,
+ error: `Cannot change namespace customization for: ${blocked.join(
+ ', '
+ )}. Allowed prefixes in this space: ${(allowedPrefixes ?? []).join(', ')}`,
+ };
+ }
+
+ const { namespaceCustomizationDiff } = await updatePackage({
+ savedObjectsClient,
+ pkgName: packageName,
+ namespace_customization_enabled_for: newList,
+ });
+ persistedList = newList;
+
+ if (
+ namespaceCustomizationDiff.addedNamespaces.length > 0 ||
+ namespaceCustomizationDiff.removedNamespaces.length > 0
+ ) {
+ await scheduleSyncNamespaceTemplatesTask(taskManagerStart, {
+ spaceId,
+ packageName,
+ addedNamespaces: namespaceCustomizationDiff.addedNamespaces,
+ removedNamespaces: namespaceCustomizationDiff.removedNamespaces,
+ });
+ }
+
+ return {
+ name: packageName,
+ success: true,
+ namespace_customization_enabled_for: newList,
+ };
+ } catch (err) {
+ return {
+ name: packageName,
+ success: false,
+ namespace_customization_enabled_for: persistedList,
+ error: err instanceof Error ? err.message : String(err),
+ };
+ }
+ },
+ { concurrency: BULK_NAMESPACE_CUSTOMIZATION_CONCURRENCY }
+ );
+
+ return response.ok({ body: { items } });
+};
diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/epm/examples/put_update_package.yaml b/x-pack/platform/plugins/shared/fleet/server/routes/epm/examples/put_update_package.yaml
index b69f100f1db30..13147a571bfb3 100644
--- a/x-pack/platform/plugins/shared/fleet/server/routes/epm/examples/put_update_package.yaml
+++ b/x-pack/platform/plugins/shared/fleet/server/routes/epm/examples/put_update_package.yaml
@@ -6,6 +6,10 @@ requestBody:
description: 'Update keep_policies_up_to_date setting for a package'
value:
keepPoliciesUpToDate: true
+ putUpdatePackageNamespaceCustomizationExample:
+ description: 'Enable namespace-level customization for the `production` and `staging` namespaces'
+ value:
+ namespace_customization_enabled_for: ['production', 'staging']
responses:
200:
description: 'Successful response'
diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/epm/handlers.ts b/x-pack/platform/plugins/shared/fleet/server/routes/epm/handlers.ts
index 16f41cb457b5f..2e95960fc6b07 100644
--- a/x-pack/platform/plugins/shared/fleet/server/routes/epm/handlers.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/routes/epm/handlers.ts
@@ -9,6 +9,7 @@ import type { TypeOf } from '@kbn/config-schema';
import semverValid from 'semver/functions/valid';
import { type HttpResponseOptions } from '@kbn/core/server';
+import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { omit, pick } from 'lodash';
@@ -95,7 +96,16 @@ import {
licenseService,
packagePolicyService,
} from '../../services';
-import { getPackageUsageStats, getPackageDependencies } from '../../services/epm/packages/get';
+import {
+ getInstallation,
+ getPackageUsageStats,
+ getPackageDependencies,
+} from '../../services/epm/packages/get';
+import {
+ getAllowedNamespacePrefixesForSpace,
+ isNamespaceAllowedByPrefixes,
+} from '../../services/spaces/policy_namespaces';
+import { PolicyNamespaceValidationError } from '../../../common/errors';
import {
bulkRollbackAvailableCheck,
isIntegrationRollbackTTLExpired,
@@ -103,6 +113,7 @@ import {
rollbackInstallation,
} from '../../services/epm/packages/rollback';
import { updatePackage, reviewUpgrade } from '../../services/epm/packages/update';
+import { scheduleSyncNamespaceTemplatesTask } from '../../tasks/sync_namespace_templates_task';
import { getGpgKeyIdOrUndefined } from '../../services/epm/packages/package_verification';
import type {
ReauthorizeTransformRequestSchema,
@@ -307,6 +318,14 @@ export const getBulkAssetsHandler: FleetRequestHandler<
return response.ok({ body });
};
+function getTaskManagerStart() {
+ const taskManagerStart = appContextService.getTaskManagerStart();
+ if (!taskManagerStart) {
+ throw new Error('Task manager not defined');
+ }
+ return taskManagerStart;
+}
+
export const updatePackageHandler: FleetRequestHandler<
| TypeOf
| TypeOf,
@@ -315,10 +334,49 @@ export const updatePackageHandler: FleetRequestHandler<
> = async (context, request, response) => {
const savedObjectsClient = (await context.fleet).internalSoClient;
const { pkgName } = request.params;
+ const spaceId = savedObjectsClient.getCurrentNamespace() ?? DEFAULT_SPACE_ID;
+
+ // Gate both added and removed namespaces on the current space's allowed_namespace_prefixes.
+ const requestedList = request.body.namespace_customization_enabled_for;
+ if (requestedList) {
+ const installation = await getInstallation({ savedObjectsClient, pkgName });
+ const currentList = installation?.namespace_customization_enabled_for ?? [];
+ const added = requestedList.filter((ns) => !currentList.includes(ns));
+ const removed = currentList.filter((ns) => !requestedList.includes(ns));
+ const changed = [...added, ...removed];
+ if (changed.length > 0) {
+ const prefixes = await getAllowedNamespacePrefixesForSpace(spaceId);
+ const blocked = changed.filter((ns) => !isNamespaceAllowedByPrefixes(ns, prefixes));
+ if (blocked.length > 0) {
+ throw new PolicyNamespaceValidationError(
+ `Cannot change namespace customization for: ${blocked.join(
+ ', '
+ )}. Allowed prefixes in this space: ${(prefixes ?? []).join(', ')}`
+ );
+ }
+ }
+ }
+
+ const { packageInfo, namespaceCustomizationDiff } = await updatePackage({
+ savedObjectsClient,
+ pkgName,
+ ...request.body,
+ });
+
+ if (
+ namespaceCustomizationDiff.addedNamespaces.length > 0 ||
+ namespaceCustomizationDiff.removedNamespaces.length > 0
+ ) {
+ await scheduleSyncNamespaceTemplatesTask(getTaskManagerStart(), {
+ spaceId,
+ packageName: pkgName,
+ addedNamespaces: namespaceCustomizationDiff.addedNamespaces,
+ removedNamespaces: namespaceCustomizationDiff.removedNamespaces,
+ });
+ }
- const res = await updatePackage({ savedObjectsClient, pkgName, ...request.body });
const body: UpdatePackageResponse = {
- item: res,
+ item: packageInfo,
};
return response.ok({ body });
@@ -745,6 +803,7 @@ const soToInstallationInfo = (pkg: PackageListItem | PackageInfo) => {
is_rollback_ttl_expired: isIntegrationRollbackTTLExpired(attributes.install_started_at),
pending_upgrade_review: attributes.pending_upgrade_review,
keep_policies_up_to_date: attributes.keep_policies_up_to_date,
+ namespace_customization_enabled_for: attributes.namespace_customization_enabled_for,
};
return {
diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.ts b/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.ts
index bb046d44c91d0..cc299bbc3e8d3 100644
--- a/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.ts
@@ -84,6 +84,8 @@ import {
GetKnowledgeBaseResponseSchema,
BulkRollbackPackagesRequestSchema,
BulkRollbackPackagesResponseSchema,
+ BulkNamespaceCustomizationRequestSchema,
+ BulkNamespaceCustomizationResponseSchema,
InstallRuleAssetsRequestSchema,
} from '../../types';
import type { FleetConfigType } from '../../config';
@@ -127,6 +129,7 @@ import {
postBulkUninstallPackagesHandler,
getOneBulkOperationPackagesHandler,
postBulkRollbackPackagesHandler,
+ postBulkNamespaceCustomizationHandler,
} from './bulk_handler';
import { deletePackageDatastreamAssetsHandler } from './package_datastream_assets_handler';
@@ -1100,6 +1103,97 @@ export const registerRoutes = (router: FleetAuthzRouter, config: FleetConfigType
);
}
+ router.versioned
+ .post({
+ path: EPM_API_ROUTES.BULK_NAMESPACE_CUSTOMIZATION_PATTERN,
+ security: INSTALL_PACKAGES_SECURITY,
+ summary: `Bulk enable/disable namespace-level customization for packages`,
+ description: `Enable or disable namespace-level index template customization for a list of packages in one call. Use this for IaC-style declarative flows.`,
+ options: {
+ tags: ['oas-tag:Elastic Package Manager (EPM)'],
+ },
+ })
+ .addVersion(
+ {
+ version: API_VERSIONS.public.v1,
+ options: {
+ oasOperationObject: () => ({
+ requestBody: {
+ content: {
+ 'application/json': {
+ examples: {
+ bulkNamespaceCustomizationRequest: {
+ value: {
+ packages: ['system', 'nginx'],
+ enable: ['production', 'staging'],
+ disable: ['dev'],
+ },
+ },
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ content: {
+ 'application/json': {
+ examples: {
+ successResponse: {
+ value: {
+ items: [
+ {
+ name: 'system',
+ success: true,
+ namespace_customization_enabled_for: ['production', 'staging'],
+ },
+ {
+ name: 'nginx',
+ success: false,
+ error: 'Package nginx is not installed',
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ },
+ 400: {
+ content: {
+ 'application/json': {
+ examples: {
+ badRequestResponse: {
+ value: {
+ statusCode: 400,
+ error: 'Bad Request',
+ message:
+ 'Namespaces must not appear in both enable and disable: production',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ },
+ validate: {
+ request: BulkNamespaceCustomizationRequestSchema,
+ response: {
+ 200: {
+ body: () => BulkNamespaceCustomizationResponseSchema,
+ description: 'OK: A successful request.',
+ },
+ 400: {
+ body: genericErrorResponse,
+ description: 'A bad request.',
+ },
+ },
+ },
+ },
+ postBulkNamespaceCustomizationHandler
+ );
+
router.versioned
.get({
path: EPM_API_ROUTES.BULK_UNINSTALL_INFO_PATTERN,
diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.test.ts b/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.test.ts
index 2d263a4b66686..8a4827b888cde 100644
--- a/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.test.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.test.ts
@@ -14,9 +14,10 @@ import { SettingsResponseSchema, SpaceSettingsResponseSchema } from '../../types
import { getSettingsHandler, getSpaceSettingsHandler } from './settings_handler';
jest.mock('../../services/spaces/space_settings', () => ({
- getSpaceSettings: jest
- .fn()
- .mockResolvedValue({ allowed_namespace_prefixes: [], managed_by: 'kibana' }),
+ getSpaceSettings: jest.fn().mockResolvedValue({
+ allowed_namespace_prefixes: [],
+ managed_by: 'kibana',
+ }),
saveSpaceSettings: jest.fn(),
}));
@@ -57,7 +58,12 @@ describe('SettingsHandler', () => {
it('should return valid space settings', async () => {
await getSpaceSettingsHandler(context, {} as any, response);
- const expectedResponse = { item: { allowed_namespace_prefixes: [], managed_by: 'kibana' } };
+ const expectedResponse = {
+ item: {
+ allowed_namespace_prefixes: [],
+ managed_by: 'kibana',
+ },
+ };
expect(response.ok).toHaveBeenCalledWith({
body: expectedResponse,
});
diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.ts b/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.ts
index eeda186a4bc4a..fce52d959f29c 100644
--- a/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.ts
@@ -6,6 +6,7 @@
*/
import type { TypeOf } from '@kbn/config-schema';
+import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import type {
FleetRequestHandler,
@@ -31,13 +32,17 @@ export const putSpaceSettingsHandler: FleetRequestHandler<
TypeOf
> = async (context, request, response) => {
const soClient = (await context.fleet).internalSoClient;
+ const spaceId = soClient.getCurrentNamespace() ?? DEFAULT_SPACE_ID;
+
await saveSpaceSettings({
settings: {
allowed_namespace_prefixes: request.body.allowed_namespace_prefixes,
},
- spaceId: soClient.getCurrentNamespace(),
+ spaceId,
});
- const settings = await getSpaceSettings(soClient.getCurrentNamespace());
+
+ const settings = await getSpaceSettings(spaceId);
+
const body = {
item: settings,
};
diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/install.ts
index 9460803e6a54c..4f5bba08280f1 100644
--- a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/install.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/install.ts
@@ -56,6 +56,7 @@ import {
generateTemplateIndexPattern,
getTemplate,
getTemplatePriority,
+ isNamespaceTemplate,
} from './template';
import { buildDefaultSettings, getILMMigrationStatus } from './default_settings';
import { isUserSettingsTemplate } from './utils';
@@ -73,11 +74,17 @@ export const prepareToInstallTemplates = async (
install: (esClient: ElasticsearchClient, logger: Logger) => Promise;
}> => {
const { packageInfo } = packageInstallContext;
- // remove package installation's references to index templates
+ // Remove package installation's references to index templates, but preserve
+ // namespace-scoped index templates (e.g. `logs-nginx.access@namespace.production`)
+ // — they are rebuilt separately in handleNamespaceTemplateRestoreAfterPackageInstall
+ // after base templates are in place. Note: `@custom` component template
+ // refs ARE removed here (they don't match `@namespace.`) and are re-added during
+ // the restore step.
const assetsToRemove = esReferences.filter(
- ({ type }) =>
- type === ElasticsearchAssetType.indexTemplate ||
- type === ElasticsearchAssetType.componentTemplate
+ ({ type, id }) =>
+ (type === ElasticsearchAssetType.indexTemplate ||
+ type === ElasticsearchAssetType.componentTemplate) &&
+ !isNamespaceTemplate(id)
);
const fieldAssetsMap: AssetsMap = new Map();
diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/template.ts
index e09e6aa55f609..930f81f522d4a 100644
--- a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/template.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/template.ts
@@ -78,6 +78,10 @@ const DEFAULT_IGNORE_ABOVE = 1024;
const DEFAULT_TEMPLATE_PRIORITY = 200;
const DATASET_IS_PREFIX_TEMPLATE_PRIORITY = 150;
+// Namespace-scoped templates get a higher priority so ES picks them over
+// the base template for data streams belonging to that namespace.
+export const NAMESPACE_TEMPLATE_PRIORITY_BOOST = 50;
+
const META_PROP_KEYS = ['metric_type', 'unit'];
/**
@@ -877,6 +881,79 @@ export function getTemplatePriority(dataStream: RegistryDataStream): number {
}
}
+// ---------------------------------------------------------------------------
+// Namespace-scoped index template helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Returns the index template name for a namespace-scoped template.
+ * Example: `logs-nginx.access@namespace.production`
+ */
+export function generateNamespaceTemplateName(baseName: string, namespace: string): string {
+ return `${baseName}@namespace.${namespace}`;
+}
+
+/**
+ * Returns the index pattern for a namespace-scoped template.
+ *
+ * The pattern matches the data stream name exactly (no trailing wildcard on the
+ * namespace segment) so that namespaces with shared prefixes do not collide —
+ * e.g. the template for namespace `production` must not also match data streams
+ * for `production_eu` or `production_us`.
+ *
+ * Example (non-prefix): `logs-nginx.access-production`
+ * Example (dataset_is_prefix): `metrics-test.*-production`
+ * Example (OTel): `traces-generic.otel-production`
+ */
+export function generateNamespaceTemplateIndexPattern(
+ dataStream: RegistryDataStream,
+ namespace: string,
+ isOtelInputType?: boolean
+): string {
+ const baseName = getRegistryDataStreamAssetBaseName(dataStream, isOtelInputType);
+ if (!dataStream.dataset_is_prefix) {
+ return `${baseName}-${namespace}`;
+ } else {
+ return `${baseName}.*-${namespace}`;
+ }
+}
+
+/**
+ * Returns the priority for a namespace-scoped index template.
+ * Always higher than the base template so ES picks it for matching data streams.
+ *
+ * Note: for data streams with `dataset_is_prefix: true`, the base template priority is 150
+ * and the namespace template priority is 200 — the same numeric value as a regular base
+ * template. This is intentional: Elasticsearch resolves priority ties by index pattern
+ * specificity, so the more specific namespace pattern (e.g. `metrics-test.*-production`)
+ * wins over the regular base pattern (e.g. `metrics-test.*-*`) even at equal priority.
+ */
+export function getNamespaceTemplatePriority(dataStream: RegistryDataStream): number {
+ return getTemplatePriority(dataStream) + NAMESPACE_TEMPLATE_PRIORITY_BOOST;
+}
+
+/**
+ * Returns true if the given template ID is a namespace-scoped index template,
+ * identifiable by the `@namespace.` discriminator in the name.
+ */
+export function isNamespaceTemplate(id: string): boolean {
+ return id.includes('@namespace.');
+}
+
+/**
+ * Extracts the namespace from a namespace-scoped template ID.
+ * Returns undefined if the ID is not a namespace template.
+ * Example: `logs-nginx.access@namespace.production` → `'production'`
+ */
+export function getNamespaceFromTemplateId(id: string): string | undefined {
+ const marker = '@namespace.';
+ const idx = id.indexOf(marker);
+ if (idx === -1) {
+ return undefined;
+ }
+ return id.slice(idx + marker.length);
+}
+
/**
* Returns a map of the data stream path fields to elasticsearch index pattern.
* @param dataStreams an array of RegistryDataStream objects
diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/index.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/index.ts
index 9af5668e3449b..d4b16a45eeee8 100644
--- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/index.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/index.ts
@@ -35,6 +35,13 @@ export { handleInstallPackageFailure, installPackage, ensureInstalledPackage } f
export { reinstallPackageForInstallation } from './reinstall';
export { removeInstallation } from './remove';
export { updateCustomIntegration, incrementVersionAndUpdate } from './update_custom_integration';
+export {
+ handleNamespaceTemplateRestoreAfterPackageInstall,
+ insertNamespaceCustomTemplate,
+ isNamespaceCustomizationEnabledForPackage,
+ syncNamespaceTemplates,
+} from './namespace_datastream_templates';
+export type { SyncNamespaceTemplatesSummary } from './namespace_datastream_templates';
export class PackageNotInstalledError extends Error {
constructor(pkgkey: string) {
super(`${pkgkey} is not installed`);
diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.ts
index dae7c9f8b54c7..5cf23cfe8206f 100644
--- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.ts
@@ -12,6 +12,7 @@ import {
SO_SEARCH_LIMIT,
FLEET_INSTALL_FORMAT_VERSION,
} from '../../../../../constants';
+import { handleNamespaceTemplateRestoreAfterPackageInstall } from '../..';
import type { Installation } from '../../../../../types';
import { packagePolicyService } from '../../../../package_policy';
@@ -65,6 +66,23 @@ export async function stepSaveSystemObject(context: InstallContext) {
pkgName
);
logger.debug(`Package install - Install status ${updatedPackage?.attributes?.install_status}`);
+ // Recreate namespace-scoped index templates for every namespace opted in on this
+ // package's Installation SO. On first install this is a no-op (opt-in list is empty).
+ if (packageInfo.type === 'integration') {
+ try {
+ await handleNamespaceTemplateRestoreAfterPackageInstall({
+ soClient: savedObjectsClient,
+ esClient,
+ packageName: pkgName,
+ dataStreams: packageInfo.data_streams ?? [],
+ });
+ } catch (err: any) {
+ logger.warn(
+ `[stepSaveSystemObject] Failed to restore namespace templates for ${pkgName}: ${err.message}`
+ );
+ }
+ }
+
// If the package is flagged with the `keep_policies_up_to_date` flag, upgrade its
// associated package policies after installation
if (updatedPackage.attributes.keep_policies_up_to_date) {
diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.test.ts
new file mode 100644
index 0000000000000..fa32d1780f6cb
--- /dev/null
+++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.test.ts
@@ -0,0 +1,462 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
+import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
+import { securityMock } from '@kbn/security-plugin/server/mocks';
+
+import { ElasticsearchAssetType } from '../../../../common/types';
+import { appContextService } from '../../app_context';
+import { updateCurrentWriteIndices } from '../elasticsearch/template/template';
+
+import { getInstalledPackageWithAssets, getInstallation } from './get';
+import { updateEsAssetReferences } from './es_assets_reference';
+
+import {
+ handleNamespaceTemplateRestoreAfterPackageInstall,
+ insertNamespaceCustomTemplate,
+ isNamespaceCustomizationEnabledForPackage,
+ syncNamespaceTemplates,
+} from './namespace_datastream_templates';
+
+jest.mock('./get');
+jest.mock('../elasticsearch/template/template', () => {
+ const actual = jest.requireActual('../elasticsearch/template/template');
+ return {
+ ...actual,
+ updateCurrentWriteIndices: jest.fn(),
+ };
+});
+jest.mock('./es_assets_reference');
+jest.mock('../../app_context');
+
+const mockedAppContextService = appContextService as jest.Mocked;
+mockedAppContextService.getSecuritySetup.mockImplementation(() => ({
+ ...securityMock.createSetup(),
+}));
+mockedAppContextService.getLogger.mockReturnValue({
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+} as any);
+
+const mockedGetInstalledPackageWithAssets = getInstalledPackageWithAssets as jest.MockedFunction<
+ typeof getInstalledPackageWithAssets
+>;
+const mockedGetInstallation = getInstallation as jest.MockedFunction;
+const mockedUpdateCurrentWriteIndices = updateCurrentWriteIndices as jest.MockedFunction<
+ typeof updateCurrentWriteIndices
+>;
+const mockedUpdateEsAssetReferences = updateEsAssetReferences as jest.MockedFunction<
+ typeof updateEsAssetReferences
+>;
+
+// ---------------------------------------------------------------------------
+// insertNamespaceCustomTemplate — pure function tests
+// ---------------------------------------------------------------------------
+
+describe('insertNamespaceCustomTemplate', () => {
+ it('inserts namespace entry before dataset-level @custom', () => {
+ const composedOf = [
+ 'logs-nginx.access@package',
+ 'logs@custom',
+ 'nginx@custom',
+ 'logs-nginx.access@custom',
+ ];
+ const result = insertNamespaceCustomTemplate(composedOf, 'production', 'logs-nginx.access');
+ expect(result).toEqual([
+ 'logs-nginx.access@package',
+ 'logs@custom',
+ 'nginx@custom',
+ 'production@custom',
+ 'logs-nginx.access@custom',
+ ]);
+ });
+
+ it('inserts namespace entry after last package-level @custom when no dataset entry exists', () => {
+ const composedOf = ['logs-nginx.access@package', 'logs@custom', 'nginx@custom'];
+ const result = insertNamespaceCustomTemplate(composedOf, 'default', 'logs-nginx.access');
+ expect(result).toEqual([
+ 'logs-nginx.access@package',
+ 'logs@custom',
+ 'nginx@custom',
+ 'default@custom',
+ ]);
+ });
+
+ it('appends at end when no package-level @custom entries exist', () => {
+ const composedOf = ['logs-nginx.access@package'];
+ const result = insertNamespaceCustomTemplate(composedOf, 'default', 'logs-nginx.access');
+ expect(result).toEqual(['logs-nginx.access@package', 'default@custom']);
+ });
+
+ it('is a no-op when namespace entry already present', () => {
+ const composedOf = [
+ 'logs-nginx.access@package',
+ 'logs@custom',
+ 'default@custom',
+ 'logs-nginx.access@custom',
+ ];
+ const result = insertNamespaceCustomTemplate(composedOf, 'default', 'logs-nginx.access');
+ expect(result).toEqual(composedOf);
+ });
+
+ it('does not treat hyphenated dataset-level entries as package-level entries', () => {
+ const composedOf = ['logs-nginx.access@package', 'logs@custom', 'logs-nginx.access@custom'];
+ const result = insertNamespaceCustomTemplate(composedOf, 'staging', 'logs-nginx.access');
+ expect(result).toEqual([
+ 'logs-nginx.access@package',
+ 'logs@custom',
+ 'staging@custom',
+ 'logs-nginx.access@custom',
+ ]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isNamespaceCustomizationEnabledForPackage — per-package opt-in helper
+// ---------------------------------------------------------------------------
+
+describe('isNamespaceCustomizationEnabledForPackage', () => {
+ const soClient = savedObjectsClientMock.create();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns true when namespace is in the opt-in list', async () => {
+ mockedGetInstallation.mockResolvedValue({
+ namespace_customization_enabled_for: ['production', 'staging'],
+ } as any);
+
+ await expect(
+ isNamespaceCustomizationEnabledForPackage(soClient, 'nginx', 'production')
+ ).resolves.toBe(true);
+ });
+
+ it('returns false when namespace is not in the opt-in list', async () => {
+ mockedGetInstallation.mockResolvedValue({
+ namespace_customization_enabled_for: ['production'],
+ } as any);
+
+ await expect(
+ isNamespaceCustomizationEnabledForPackage(soClient, 'nginx', 'staging')
+ ).resolves.toBe(false);
+ });
+
+ it('returns false when the package is not installed', async () => {
+ mockedGetInstallation.mockResolvedValue(undefined);
+
+ await expect(
+ isNamespaceCustomizationEnabledForPackage(soClient, 'nginx', 'production')
+ ).resolves.toBe(false);
+ });
+
+ it('returns false when the opt-in list is missing from the installation', async () => {
+ mockedGetInstallation.mockResolvedValue({} as any);
+
+ await expect(
+ isNamespaceCustomizationEnabledForPackage(soClient, 'nginx', 'production')
+ ).resolves.toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Helpers shared by the remaining test suites
+// ---------------------------------------------------------------------------
+
+const BASE_COMPOSED_OF = [
+ 'logs-nginx.access@package',
+ 'logs@custom',
+ 'nginx@custom',
+ 'logs-nginx.access@custom',
+];
+
+function mockInstalledPackage(
+ dataStreams: Array<{ dataset: string; type: string }> = [
+ { dataset: 'nginx.access', type: 'logs' },
+ ]
+) {
+ mockedGetInstalledPackageWithAssets.mockResolvedValue({
+ packageInfo: { name: 'nginx', data_streams: dataStreams },
+ installation: { installed_es: [] },
+ } as any);
+}
+
+function makeEsClientWithTemplate(composedOf: string[] = BASE_COMPOSED_OF) {
+ const esClient = elasticsearchServiceMock.createElasticsearchClient();
+ esClient.indices.getIndexTemplate.mockResolvedValue({
+ index_templates: [
+ {
+ name: 'logs-nginx.access',
+ index_template: {
+ composed_of: composedOf,
+ index_patterns: ['logs-nginx.access-*'],
+ priority: 200,
+ template: { settings: {}, mappings: {} },
+ data_stream: {},
+ _meta: { package: { name: 'nginx' } },
+ },
+ },
+ ],
+ } as any);
+ return esClient;
+}
+
+// ---------------------------------------------------------------------------
+// handleNamespaceTemplateRestoreAfterPackageInstall
+// ---------------------------------------------------------------------------
+
+describe('handleNamespaceTemplateRestoreAfterPackageInstall', () => {
+ const soClient = savedObjectsClientMock.create();
+ const dataStreams = [{ dataset: 'nginx.access', type: 'logs' }] as any[];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockedAppContextService.getLogger.mockReturnValue({
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ } as any);
+ mockedUpdateCurrentWriteIndices.mockResolvedValue(undefined);
+ mockedUpdateEsAssetReferences.mockResolvedValue([]);
+ });
+
+ it('is a no-op when there are no data streams', async () => {
+ const esClient = elasticsearchServiceMock.createElasticsearchClient();
+ await handleNamespaceTemplateRestoreAfterPackageInstall({
+ soClient,
+ esClient,
+ packageName: 'nginx',
+ dataStreams: [],
+ });
+ expect(esClient.indices.putIndexTemplate).not.toHaveBeenCalled();
+ });
+
+ it('is a no-op when the package opt-in list is empty', async () => {
+ mockedGetInstallation.mockResolvedValue({
+ namespace_customization_enabled_for: [],
+ } as any);
+ const esClient = elasticsearchServiceMock.createElasticsearchClient();
+
+ await handleNamespaceTemplateRestoreAfterPackageInstall({
+ soClient,
+ esClient,
+ packageName: 'nginx',
+ dataStreams,
+ });
+
+ expect(esClient.indices.putIndexTemplate).not.toHaveBeenCalled();
+ });
+
+ it('rebuilds namespace templates for every opted-in namespace on the package', async () => {
+ mockedGetInstallation.mockResolvedValue({
+ namespace_customization_enabled_for: ['production', 'staging'],
+ installed_es: [],
+ } as any);
+ const esClient = makeEsClientWithTemplate();
+
+ await handleNamespaceTemplateRestoreAfterPackageInstall({
+ soClient,
+ esClient,
+ packageName: 'nginx',
+ dataStreams,
+ });
+
+ const putCalls = (esClient.indices.putIndexTemplate as unknown as jest.Mock).mock.calls;
+ const templateNames = putCalls.map((c: any) => c[0].name).sort();
+ expect(templateNames).toEqual([
+ 'logs-nginx.access@namespace.production',
+ 'logs-nginx.access@namespace.staging',
+ ]);
+ });
+
+ it('tracks restored namespace templates in installed_es', async () => {
+ mockedGetInstallation.mockResolvedValue({
+ namespace_customization_enabled_for: ['production'],
+ installed_es: [],
+ } as any);
+ const esClient = makeEsClientWithTemplate();
+
+ await handleNamespaceTemplateRestoreAfterPackageInstall({
+ soClient,
+ esClient,
+ packageName: 'nginx',
+ dataStreams,
+ });
+
+ expect(mockedUpdateEsAssetReferences).toHaveBeenCalledWith(
+ soClient,
+ 'nginx',
+ expect.any(Array),
+ expect.objectContaining({
+ assetsToAdd: expect.arrayContaining([
+ {
+ id: 'logs-nginx.access@namespace.production',
+ type: ElasticsearchAssetType.indexTemplate,
+ },
+ ]),
+ })
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// syncNamespaceTemplates — per-package opt-in sync
+// ---------------------------------------------------------------------------
+
+describe('syncNamespaceTemplates', () => {
+ const soClient = savedObjectsClientMock.create();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockedAppContextService.getLogger.mockReturnValue({
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ } as any);
+ mockedUpdateCurrentWriteIndices.mockResolvedValue(undefined);
+ mockedUpdateEsAssetReferences.mockResolvedValue([]);
+ mockedGetInstallation.mockResolvedValue({ installed_es: [] } as any);
+ });
+
+ it('is a no-op when both added and removed are empty', async () => {
+ const esClient = elasticsearchServiceMock.createElasticsearchClient();
+
+ const summary = await syncNamespaceTemplates({
+ soClient,
+ esClient,
+ packageName: 'nginx',
+ addedNamespaces: [],
+ removedNamespaces: [],
+ });
+
+ expect(esClient.indices.putIndexTemplate).not.toHaveBeenCalled();
+ expect(esClient.indices.deleteIndexTemplate).not.toHaveBeenCalled();
+ expect(summary.created).toEqual([]);
+ expect(summary.removed).toEqual([]);
+ });
+
+ it('marks the summary as skipped when the package is not installed', async () => {
+ mockedGetInstalledPackageWithAssets.mockResolvedValue(undefined);
+ const esClient = elasticsearchServiceMock.createElasticsearchClient();
+
+ const summary = await syncNamespaceTemplates({
+ soClient,
+ esClient,
+ packageName: 'nginx',
+ addedNamespaces: ['production'],
+ removedNamespaces: [],
+ });
+
+ expect(esClient.indices.putIndexTemplate).not.toHaveBeenCalled();
+ expect(summary).toMatchObject({ packageName: 'nginx', skipped: true });
+ });
+
+ it('creates namespace templates for added namespaces', async () => {
+ mockInstalledPackage();
+ const esClient = makeEsClientWithTemplate();
+
+ const summary = await syncNamespaceTemplates({
+ soClient,
+ esClient,
+ packageName: 'nginx',
+ addedNamespaces: ['production'],
+ removedNamespaces: [],
+ });
+
+ expect(esClient.indices.putIndexTemplate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'logs-nginx.access@namespace.production',
+ priority: 250,
+ composed_of: expect.arrayContaining(['production@custom']),
+ }),
+ expect.any(Object)
+ );
+ expect(summary.created).toEqual(['production']);
+ expect(summary.removed).toEqual([]);
+ });
+
+ it('does not throw when no data stream exists yet for the new namespace', async () => {
+ mockInstalledPackage();
+ const esClient = makeEsClientWithTemplate();
+ // updateCurrentWriteIndices throws index_not_found when the namespace's
+ // data stream doesn't exist yet (no data ingested). The handler should
+ // swallow the 404 so the sync task can complete.
+ mockedUpdateCurrentWriteIndices.mockRejectedValueOnce({
+ meta: { statusCode: 404 },
+ message: 'index_not_found_exception',
+ });
+
+ const summary = await syncNamespaceTemplates({
+ soClient,
+ esClient,
+ packageName: 'nginx',
+ addedNamespaces: ['production'],
+ removedNamespaces: [],
+ });
+
+ expect(summary.created).toEqual(['production']);
+ });
+
+ it('deletes namespace templates for removed namespaces', async () => {
+ mockInstalledPackage();
+ const esClient = elasticsearchServiceMock.createElasticsearchClient();
+
+ const summary = await syncNamespaceTemplates({
+ soClient,
+ esClient,
+ packageName: 'nginx',
+ addedNamespaces: [],
+ removedNamespaces: ['staging'],
+ });
+
+ expect(esClient.indices.deleteIndexTemplate).toHaveBeenCalledWith(
+ { name: 'logs-nginx.access@namespace.staging' },
+ expect.objectContaining({ ignore: [404] })
+ );
+ expect(summary.removed).toEqual(['staging']);
+ expect(summary.created).toEqual([]);
+ });
+
+ it('tracks created templates and removes deleted ones from installed_es', async () => {
+ mockInstalledPackage();
+ const esClient = makeEsClientWithTemplate();
+
+ await syncNamespaceTemplates({
+ soClient,
+ esClient,
+ packageName: 'nginx',
+ addedNamespaces: ['production'],
+ removedNamespaces: ['staging'],
+ });
+
+ const updateCalls = mockedUpdateEsAssetReferences.mock.calls;
+ const assetsToAddCall = updateCalls.find((c) => c[3]?.assetsToAdd);
+ const assetsToRemoveCall = updateCalls.find((c) => c[3]?.assetsToRemove);
+
+ expect(assetsToAddCall?.[3].assetsToAdd).toEqual(
+ expect.arrayContaining([
+ {
+ id: 'logs-nginx.access@namespace.production',
+ type: ElasticsearchAssetType.indexTemplate,
+ },
+ ])
+ );
+ expect(assetsToRemoveCall?.[3].assetsToRemove).toEqual(
+ expect.arrayContaining([
+ {
+ id: 'logs-nginx.access@namespace.staging',
+ type: ElasticsearchAssetType.indexTemplate,
+ },
+ ])
+ );
+ });
+});
diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.ts
new file mode 100644
index 0000000000000..913cd7acda968
--- /dev/null
+++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.ts
@@ -0,0 +1,504 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import pMap from 'p-map';
+import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
+import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
+
+import type { IndexTemplate, IndexTemplateEntry, RegistryDataStream } from '../../../types';
+import { ElasticsearchAssetType } from '../../../../common/types';
+import { appContextService } from '../../app_context';
+import {
+ updateCurrentWriteIndices,
+ generateNamespaceTemplateName,
+ generateNamespaceTemplateIndexPattern,
+ getNamespaceTemplatePriority,
+} from '../elasticsearch/template/template';
+import { isUserSettingsTemplate } from '../elasticsearch/template/utils';
+import { getRegistryDataStreamAssetBaseName } from '../../../../common/services';
+import { MAX_CONCURRENT_COMPONENT_TEMPLATES } from '../../../constants';
+import { OTEL_COLLECTOR_INPUT_TYPE } from '../../../../common/constants';
+import { throwIfAborted } from '../../../tasks/utils';
+
+import { updateEsAssetReferences } from './es_assets_reference';
+import { getInstalledPackageWithAssets, getInstallation } from './get';
+
+/**
+ * Returns true if any of the data stream's streams use the OTel collector input type
+ * AND OTel integrations are enabled. Mirrors the check in `installTemplateForDataStream`.
+ */
+function isOtelDataStream(dataStream: RegistryDataStream): boolean {
+ const experimentalFeature = appContextService.getExperimentalFeatures();
+ return (
+ !!experimentalFeature?.enableOtelIntegrations &&
+ (dataStream?.streams || []).some((stream) => stream.input === OTEL_COLLECTOR_INPUT_TYPE)
+ );
+}
+
+/**
+ * Returns true if namespace-level customization is opted in for `namespace` on
+ * the installed `packageName`. Reads the Installation saved object's
+ * `namespace_customization_enabled_for` list.
+ */
+export async function isNamespaceCustomizationEnabledForPackage(
+ soClient: SavedObjectsClientContract,
+ packageName: string,
+ namespace: string
+): Promise {
+ const installation = await getInstallation({
+ savedObjectsClient: soClient,
+ pkgName: packageName,
+ });
+ return !!installation?.namespace_customization_enabled_for?.includes(namespace);
+}
+
+/**
+ * Inserts `@custom` into a `composed_of` array at the correct position:
+ * - after the last package-level `@custom` (e.g. `system@custom`, no hyphen before `@custom`)
+ * - before the dataset-level `@custom` (e.g. `logs-system.application@custom`)
+ *
+ * If `@custom` is already present, the array is returned unchanged.
+ * This includes the case where a namespace name matches a package name (e.g.
+ * namespace "nginx" for package "nginx") — the existing `nginx@custom` entry
+ * already serves both the package-level and namespace-level roles, so no
+ * duplicate is inserted.
+ *
+ * Used when building the `composed_of` for a namespace-scoped index template.
+ */
+export function insertNamespaceCustomTemplate(
+ composedOf: string[],
+ namespace: string,
+ templateName: string
+): string[] {
+ const namespaceEntry = `${namespace}@custom`;
+ if (composedOf.includes(namespaceEntry)) {
+ return composedOf;
+ }
+
+ const datasetEntry = `${templateName}@custom`;
+ // Package-level @custom: name contains no hyphen before `@custom`
+ // e.g. "system@custom" matches, "logs@custom" matches
+ // but "logs-system.application@custom" does NOT
+ const isPackageLevelCustom = (name: string): boolean =>
+ name.endsWith('@custom') && !name.slice(0, -7).includes('-');
+
+ let insertAt: number;
+ const datasetIdx = composedOf.indexOf(datasetEntry);
+
+ if (datasetIdx !== -1) {
+ // Insert immediately before the dataset-level entry
+ insertAt = datasetIdx;
+ } else {
+ // Find the last package-level @custom entry and insert after it
+ let lastPkgCustomIdx = -1;
+ for (let i = 0; i < composedOf.length; i++) {
+ if (isPackageLevelCustom(composedOf[i])) {
+ lastPkgCustomIdx = i;
+ }
+ }
+ insertAt = lastPkgCustomIdx !== -1 ? lastPkgCustomIdx + 1 : composedOf.length;
+ }
+
+ const result = [...composedOf];
+ result.splice(insertAt, 0, namespaceEntry);
+ return result;
+}
+
+/**
+ * Fetches a base index template from ES and strips read-only date properties.
+ * Returns the cleaned template or undefined if not found.
+ */
+async function fetchBaseTemplate(
+ esClient: ElasticsearchClient,
+ templateName: string,
+ logContext: string,
+ abortController?: AbortController
+): Promise {
+ const logger = appContextService.getLogger();
+ let rawTemplate;
+ try {
+ const res = await esClient.indices.getIndexTemplate(
+ { name: templateName },
+ { signal: abortController?.signal }
+ );
+ rawTemplate = res.index_templates[0]?.index_template;
+ } catch (err: unknown) {
+ if ((err as { meta?: { statusCode?: number } })?.meta?.statusCode !== 404) {
+ throw err;
+ }
+ logger.debug(`[${logContext}] index template ${templateName} not found, skipping`);
+ return undefined;
+ }
+
+ if (!rawTemplate) {
+ return undefined;
+ }
+
+ // Strip system-managed date properties that cannot be set on PUT
+ const {
+ created_date: _cd,
+ created_date_millis: _cdm,
+ modified_date: _md,
+ modified_date_millis: _mdm,
+ ...indexTemplate
+ } = rawTemplate as IndexTemplate;
+
+ return indexTemplate;
+}
+
+/**
+ * Builds a namespace-scoped index template from a base template.
+ * The namespace template has a more specific index pattern, higher priority,
+ * and includes `@custom` in its `composed_of`.
+ */
+function buildNamespaceTemplate({
+ baseTemplate,
+ dataStream,
+ namespace,
+ templateName,
+ isOtelInputType,
+}: {
+ baseTemplate: IndexTemplate;
+ dataStream: RegistryDataStream;
+ namespace: string;
+ templateName: string;
+ isOtelInputType?: boolean;
+}): { name: string; template: IndexTemplate } {
+ const nsTemplateName = generateNamespaceTemplateName(templateName, namespace);
+ const nsIndexPattern = generateNamespaceTemplateIndexPattern(
+ dataStream,
+ namespace,
+ isOtelInputType
+ );
+ const nsPriority = getNamespaceTemplatePriority(dataStream);
+
+ const composedOf = insertNamespaceCustomTemplate(
+ [...(baseTemplate.composed_of ?? [])],
+ namespace,
+ templateName
+ );
+
+ const ignoreMissing = composedOf.filter(isUserSettingsTemplate);
+
+ const nsTemplate: IndexTemplate = {
+ ...baseTemplate,
+ index_patterns: [nsIndexPattern],
+ priority: nsPriority,
+ composed_of: composedOf,
+ ignore_missing_component_templates: ignoreMissing,
+ };
+
+ return { name: nsTemplateName, template: nsTemplate };
+}
+
+/**
+ * Creates namespace-scoped index templates for every `(dataStream, namespace)` pair
+ * and tracks them in `installed_es`. Returns the created template names.
+ */
+async function createNamespaceTemplatesForPackage({
+ soClient,
+ esClient,
+ packageName,
+ dataStreams,
+ namespaces,
+ logContext,
+ abortController,
+}: {
+ soClient: SavedObjectsClientContract;
+ esClient: ElasticsearchClient;
+ packageName: string;
+ dataStreams: RegistryDataStream[];
+ namespaces: string[];
+ logContext: string;
+ abortController?: AbortController;
+}): Promise {
+ if (dataStreams.length === 0 || namespaces.length === 0) {
+ return [];
+ }
+ const logger = appContextService.getLogger();
+ const updatedIndexTemplates: IndexTemplateEntry[] = [];
+
+ await pMap(
+ dataStreams,
+ async (dataStream) => {
+ if (abortController) throwIfAborted(abortController);
+ const isOtelInputType = isOtelDataStream(dataStream);
+ const templateName = getRegistryDataStreamAssetBaseName(dataStream, isOtelInputType);
+ const baseTemplate = await fetchBaseTemplate(
+ esClient,
+ templateName,
+ logContext,
+ abortController
+ );
+ if (!baseTemplate) return;
+
+ for (const namespace of namespaces) {
+ const { name: nsName, template: nsTemplate } = buildNamespaceTemplate({
+ baseTemplate,
+ dataStream,
+ namespace,
+ templateName,
+ isOtelInputType,
+ });
+
+ await esClient.indices.putIndexTemplate(
+ { name: nsName, ...nsTemplate },
+ { signal: abortController?.signal }
+ );
+ updatedIndexTemplates.push({ templateName: nsName, indexTemplate: nsTemplate });
+ }
+ },
+ { concurrency: MAX_CONCURRENT_COMPONENT_TEMPLATES }
+ );
+
+ if (updatedIndexTemplates.length === 0) {
+ return [];
+ }
+
+ if (abortController) throwIfAborted(abortController);
+ // A user can opt in a namespace before any data stream for that namespace exists
+ // (no data has been ingested yet). In that case `getDataStream` 404s on the
+ // namespace-scoped pattern; nothing to update, so just continue.
+ try {
+ await updateCurrentWriteIndices(esClient, logger, updatedIndexTemplates);
+ } catch (err: unknown) {
+ if ((err as { meta?: { statusCode?: number } })?.meta?.statusCode !== 404) {
+ throw err;
+ }
+ logger.debug(`[${logContext}] no existing data streams to update for new namespace templates`);
+ }
+
+ const freshInstallation = await getInstallation({
+ savedObjectsClient: soClient,
+ pkgName: packageName,
+ });
+ const assetsToAdd = updatedIndexTemplates.map(({ templateName }) => ({
+ id: templateName,
+ type: ElasticsearchAssetType.indexTemplate,
+ }));
+ await updateEsAssetReferences(soClient, packageName, freshInstallation?.installed_es ?? [], {
+ assetsToAdd,
+ });
+
+ return updatedIndexTemplates.map(({ templateName }) => templateName);
+}
+
+/**
+ * Deletes namespace-scoped index templates for the given `(dataStream, namespace)`
+ * pairs and removes them from `installed_es`. Returns the deleted template names.
+ */
+async function deleteNamespaceTemplatesForPackage({
+ soClient,
+ esClient,
+ packageName,
+ dataStreams,
+ namespaces,
+ logContext,
+ abortController,
+}: {
+ soClient: SavedObjectsClientContract;
+ esClient: ElasticsearchClient;
+ packageName: string;
+ dataStreams: RegistryDataStream[];
+ namespaces: string[];
+ logContext: string;
+ abortController?: AbortController;
+}): Promise {
+ if (dataStreams.length === 0 || namespaces.length === 0) {
+ return [];
+ }
+ const logger = appContextService.getLogger();
+ const deleted: string[] = [];
+
+ await pMap(
+ dataStreams,
+ async (dataStream) => {
+ if (abortController) throwIfAborted(abortController);
+ const templateName = getRegistryDataStreamAssetBaseName(
+ dataStream,
+ isOtelDataStream(dataStream)
+ );
+ for (const namespace of namespaces) {
+ const nsName = generateNamespaceTemplateName(templateName, namespace);
+ try {
+ await esClient.indices.deleteIndexTemplate(
+ { name: nsName },
+ { ignore: [404], signal: abortController?.signal }
+ );
+ deleted.push(nsName);
+ } catch (err: unknown) {
+ logger.warn(
+ `[${logContext}] Failed to delete namespace template ${nsName}: ${
+ err instanceof Error ? err.message : String(err)
+ }`
+ );
+ }
+ }
+ },
+ { concurrency: MAX_CONCURRENT_COMPONENT_TEMPLATES }
+ );
+
+ if (deleted.length === 0) {
+ return [];
+ }
+
+ const freshInstallation = await getInstallation({
+ savedObjectsClient: soClient,
+ pkgName: packageName,
+ });
+ const assetsToRemove = deleted.map((id) => ({
+ id,
+ type: ElasticsearchAssetType.indexTemplate,
+ }));
+ await updateEsAssetReferences(soClient, packageName, freshInstallation?.installed_es ?? [], {
+ assetsToRemove,
+ });
+
+ return deleted;
+}
+
+// ---------------------------------------------------------------------------
+// Package reinstall/upgrade hook
+// ---------------------------------------------------------------------------
+
+/**
+ * After a package is (re)installed, rebuild the namespace-scoped index templates for
+ * every namespace currently opted-in on the package (via
+ * `Installation.namespace_customization_enabled_for`). Called from the state machine's
+ * final step so namespace templates survive reinstalls and upgrades.
+ *
+ * On first install the opt-in list is empty, so this is a no-op.
+ */
+export async function handleNamespaceTemplateRestoreAfterPackageInstall({
+ soClient,
+ esClient,
+ packageName,
+ dataStreams,
+}: {
+ soClient: SavedObjectsClientContract;
+ esClient: ElasticsearchClient;
+ packageName: string;
+ dataStreams: RegistryDataStream[];
+}) {
+ if (dataStreams.length === 0) {
+ return;
+ }
+
+ const installation = await getInstallation({
+ savedObjectsClient: soClient,
+ pkgName: packageName,
+ });
+ const namespaces = installation?.namespace_customization_enabled_for ?? [];
+ if (namespaces.length === 0) {
+ return;
+ }
+
+ await createNamespaceTemplatesForPackage({
+ soClient,
+ esClient,
+ packageName,
+ dataStreams,
+ namespaces,
+ logContext: 'handleNamespaceTemplateRestoreAfterPackageInstall',
+ });
+}
+
+// ---------------------------------------------------------------------------
+// syncNamespaceTemplates — opt-in driven, per-package
+// ---------------------------------------------------------------------------
+
+export interface SyncNamespaceTemplatesSummary {
+ packageName: string;
+ created: string[]; // namespace names for which templates were created
+ removed: string[]; // namespace names for which templates were deleted
+ skipped: boolean;
+}
+
+/**
+ * Creates or deletes namespace-scoped index templates for a single package, driven
+ * by additions to and removals from `Installation.namespace_customization_enabled_for`.
+ *
+ * Called from the `fleet:sync_namespace_templates` task after the Installation SO's
+ * opt-in list has been updated by the API handler.
+ */
+export async function syncNamespaceTemplates({
+ soClient,
+ esClient,
+ packageName,
+ addedNamespaces,
+ removedNamespaces,
+ abortController,
+}: {
+ soClient: SavedObjectsClientContract;
+ esClient: ElasticsearchClient;
+ packageName: string;
+ addedNamespaces: string[];
+ removedNamespaces: string[];
+ abortController?: AbortController;
+}): Promise {
+ const summary: SyncNamespaceTemplatesSummary = {
+ packageName,
+ created: [],
+ removed: [],
+ skipped: false,
+ };
+ if (addedNamespaces.length === 0 && removedNamespaces.length === 0) {
+ return summary;
+ }
+
+ const installedPkg = await getInstalledPackageWithAssets({
+ savedObjectsClient: soClient,
+ pkgName: packageName,
+ });
+ if (!installedPkg) {
+ appContextService
+ .getLogger()
+ .debug(`[syncNamespaceTemplates] Package ${packageName} not installed, skipping`);
+ summary.skipped = true;
+ return summary;
+ }
+
+ const { packageInfo } = installedPkg;
+ const dataStreams = packageInfo.data_streams ?? [];
+ if (dataStreams.length === 0) {
+ return summary;
+ }
+
+ if (addedNamespaces.length > 0) {
+ if (abortController) throwIfAborted(abortController);
+ const createdTemplates = await createNamespaceTemplatesForPackage({
+ soClient,
+ esClient,
+ packageName,
+ dataStreams,
+ namespaces: addedNamespaces,
+ logContext: 'syncNamespaceTemplates',
+ abortController,
+ });
+ if (createdTemplates.length > 0) {
+ summary.created = addedNamespaces;
+ }
+ }
+
+ if (removedNamespaces.length > 0) {
+ if (abortController) throwIfAborted(abortController);
+ const deletedTemplates = await deleteNamespaceTemplatesForPackage({
+ soClient,
+ esClient,
+ packageName,
+ dataStreams,
+ namespaces: removedNamespaces,
+ logContext: 'syncNamespaceTemplates',
+ abortController,
+ });
+ if (deletedTemplates.length > 0) {
+ summary.removed = removedNamespaces;
+ }
+ }
+
+ return summary;
+}
diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/update.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/update.ts
index d142c47e09adc..12aa0a13fd4c8 100644
--- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/update.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/update.ts
@@ -18,14 +18,27 @@ import { auditLoggingService } from '../../audit_logging';
import { getInstallationObject, getPackageInfo } from './get';
+export interface NamespaceCustomizationDiff {
+ addedNamespaces: string[];
+ removedNamespaces: string[];
+}
+
export async function updatePackage(
options: {
savedObjectsClient: SavedObjectsClientContract;
pkgName: string;
keepPoliciesUpToDate?: boolean;
} & TypeOf
-) {
- const { savedObjectsClient, pkgName, keepPoliciesUpToDate } = options;
+): Promise<{
+ packageInfo: Awaited>;
+ namespaceCustomizationDiff: NamespaceCustomizationDiff;
+}> {
+ const {
+ savedObjectsClient,
+ pkgName,
+ keepPoliciesUpToDate,
+ namespace_customization_enabled_for: newNamespaceCustomization,
+ } = options;
const installedPackage = await getInstallationObject({ savedObjectsClient, pkgName });
if (!installedPackage) {
@@ -39,12 +52,27 @@ export async function updatePackage(
savedObjectType: PACKAGES_SAVED_OBJECT_TYPE,
});
- const updateAttrs: Partial = {
- keep_policies_up_to_date: keepPoliciesUpToDate ?? false,
+ const updateAttrs: Partial = {};
+ const namespaceCustomizationDiff: NamespaceCustomizationDiff = {
+ addedNamespaces: [],
+ removedNamespaces: [],
};
- if (keepPoliciesUpToDate === false) {
- updateAttrs.pending_upgrade_review = undefined;
+
+ if (keepPoliciesUpToDate !== undefined) {
+ updateAttrs.keep_policies_up_to_date = keepPoliciesUpToDate;
+ if (keepPoliciesUpToDate === false) {
+ updateAttrs.pending_upgrade_review = undefined;
+ }
+ }
+
+ if (newNamespaceCustomization) {
+ const oldList = installedPackage.attributes.namespace_customization_enabled_for ?? [];
+ const newList = [...new Set(newNamespaceCustomization)];
+ namespaceCustomizationDiff.addedNamespaces = newList.filter((ns) => !oldList.includes(ns));
+ namespaceCustomizationDiff.removedNamespaces = oldList.filter((ns) => !newList.includes(ns));
+ updateAttrs.namespace_customization_enabled_for = newList;
}
+
await savedObjectsClient.update(
PACKAGES_SAVED_OBJECT_TYPE,
installedPackage.id,
@@ -57,7 +85,7 @@ export async function updatePackage(
pkgVersion: installedPackage.attributes.version,
});
- return packageInfo;
+ return { packageInfo, namespaceCustomizationDiff };
}
export async function reviewUpgrade(options: {
diff --git a/x-pack/platform/plugins/shared/fleet/server/services/spaces/policy_namespaces.ts b/x-pack/platform/plugins/shared/fleet/server/services/spaces/policy_namespaces.ts
index dae04aed32373..1d1a27f13b5c1 100644
--- a/x-pack/platform/plugins/shared/fleet/server/services/spaces/policy_namespaces.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/services/spaces/policy_namespaces.ts
@@ -21,6 +21,38 @@ import { PackagePolicyNameExistsError } from '../../errors';
import { getSpaceSettings } from './space_settings';
+/**
+ * Returns the allowed namespace prefixes for the given Kibana space, or `null`
+ * if no restriction applies (space awareness disabled or no prefixes configured).
+ */
+export async function getAllowedNamespacePrefixesForSpace(
+ spaceId?: string
+): Promise {
+ const experimentalFeature = appContextService.getExperimentalFeatures();
+ if (!experimentalFeature.useSpaceAwareness) {
+ return null;
+ }
+ const settings = await getSpaceSettings(spaceId);
+ if (!settings.allowed_namespace_prefixes || settings.allowed_namespace_prefixes.length === 0) {
+ return null;
+ }
+ return settings.allowed_namespace_prefixes;
+}
+
+/**
+ * Returns true if the namespace is permitted by the given prefix list.
+ * `null` means no restriction (anything is permitted).
+ */
+export function isNamespaceAllowedByPrefixes(
+ namespace: string,
+ prefixes: string[] | null
+): boolean {
+ if (prefixes === null) {
+ return true;
+ }
+ return prefixes.some((prefix) => namespace.startsWith(prefix));
+}
+
export async function validatePolicyNamespaceForSpace({
namespace,
spaceId,
diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.test.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.test.ts
new file mode 100644
index 0000000000000..f23f363b9069c
--- /dev/null
+++ b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.test.ts
@@ -0,0 +1,169 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
+
+import { appContextService } from '../services';
+import { syncNamespaceTemplates } from '../services/epm/packages';
+
+import {
+ registerSyncNamespaceTemplatesTask,
+ scheduleSyncNamespaceTemplatesTask,
+} from './sync_namespace_templates_task';
+
+jest.mock('../services');
+jest.mock('../services/epm/packages');
+
+const mockedSyncNamespaceTemplates = syncNamespaceTemplates as jest.MockedFunction<
+ typeof syncNamespaceTemplates
+>;
+
+const mockSoClient = { getCurrentNamespace: jest.fn().mockReturnValue('default') } as any;
+const mockEsClient = {} as any;
+
+describe('syncNamespaceTemplatesTask', () => {
+ const logger = {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (appContextService.getLogger as jest.Mock).mockReturnValue(logger);
+ (appContextService.getInternalUserSOClientForSpaceId as jest.Mock).mockReturnValue(
+ mockSoClient
+ );
+ (appContextService.getInternalUserESClient as jest.Mock).mockReturnValue(mockEsClient);
+ mockedSyncNamespaceTemplates.mockResolvedValue({
+ packageName: 'nginx',
+ created: [],
+ removed: [],
+ skipped: false,
+ });
+ });
+
+ describe('registerSyncNamespaceTemplatesTask', () => {
+ it('should register the task definition', () => {
+ const taskManager = taskManagerMock.createSetup();
+ registerSyncNamespaceTemplatesTask(taskManager);
+ expect(taskManager.registerTaskDefinitions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ 'fleet:sync_namespace_templates': expect.objectContaining({
+ title: 'Fleet Sync namespace templates',
+ timeout: '15m',
+ maxAttempts: 3,
+ }),
+ })
+ );
+ });
+ });
+
+ describe('scheduleSyncNamespaceTemplatesTask', () => {
+ it('should schedule the task with correct parameters', async () => {
+ const taskManager = taskManagerMock.createStart();
+ await scheduleSyncNamespaceTemplatesTask(taskManager, {
+ spaceId: 'default',
+ packageName: 'nginx',
+ addedNamespaces: ['production'],
+ removedNamespaces: [],
+ });
+ expect(taskManager.ensureScheduled).toHaveBeenCalledWith(
+ expect.objectContaining({
+ taskType: 'fleet:sync_namespace_templates',
+ scope: ['fleet'],
+ params: {
+ spaceId: 'default',
+ packageName: 'nginx',
+ addedNamespaces: ['production'],
+ removedNamespaces: [],
+ },
+ state: {},
+ })
+ );
+ });
+
+ it('should not schedule when there are no namespace changes', async () => {
+ const taskManager = taskManagerMock.createStart();
+ await scheduleSyncNamespaceTemplatesTask(taskManager, {
+ spaceId: 'default',
+ packageName: 'nginx',
+ addedNamespaces: [],
+ removedNamespaces: [],
+ });
+ expect(taskManager.ensureScheduled).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('task runner', () => {
+ const createTaskRunner = (params: Record) => {
+ const taskManager = taskManagerMock.createSetup();
+ registerSyncNamespaceTemplatesTask(taskManager);
+ const registeredDef =
+ taskManager.registerTaskDefinitions.mock.calls[0][0]['fleet:sync_namespace_templates'];
+ const abortController = new AbortController();
+ const runner = registeredDef.createTaskRunner({
+ taskInstance: { params } as any,
+ abortController,
+ });
+ return { runner, abortController };
+ };
+
+ it('should call syncNamespaceTemplates with correct parameters', async () => {
+ const { runner, abortController } = createTaskRunner({
+ spaceId: 'my_space',
+ packageName: 'nginx',
+ addedNamespaces: ['production'],
+ removedNamespaces: ['staging'],
+ });
+
+ await runner.run();
+
+ expect(appContextService.getInternalUserSOClientForSpaceId).toHaveBeenCalledWith('my_space');
+ expect(appContextService.getInternalUserESClient).toHaveBeenCalled();
+ expect(mockedSyncNamespaceTemplates).toHaveBeenCalledWith({
+ soClient: mockSoClient,
+ esClient: mockEsClient,
+ packageName: 'nginx',
+ addedNamespaces: ['production'],
+ removedNamespaces: ['staging'],
+ abortController,
+ });
+ });
+
+ it('should be a no-op when there are no namespace changes', async () => {
+ const { runner } = createTaskRunner({
+ spaceId: 'default',
+ packageName: 'nginx',
+ addedNamespaces: [],
+ removedNamespaces: [],
+ });
+
+ await runner.run();
+
+ expect(mockedSyncNamespaceTemplates).not.toHaveBeenCalled();
+ });
+
+ it('should pass an aborted signal to syncNamespaceTemplates when the task is cancelled', async () => {
+ const { runner, abortController } = createTaskRunner({
+ spaceId: 'default',
+ packageName: 'nginx',
+ addedNamespaces: ['production'],
+ removedNamespaces: [],
+ });
+
+ abortController.abort();
+ await runner.run();
+
+ expect(mockedSyncNamespaceTemplates).toHaveBeenCalledWith(
+ expect.objectContaining({ abortController })
+ );
+ expect(abortController.signal.aborted).toBe(true);
+ });
+ });
+});
diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.ts
new file mode 100644
index 0000000000000..0da7e4e28b3af
--- /dev/null
+++ b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.ts
@@ -0,0 +1,95 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type {
+ TaskManagerSetupContract,
+ TaskManagerStartContract,
+} from '@kbn/task-manager-plugin/server';
+import { v4 as uuidv4 } from 'uuid';
+
+import { appContextService } from '../services';
+import { syncNamespaceTemplates } from '../services/epm/packages';
+
+const TASK_TYPE = 'fleet:sync_namespace_templates';
+
+export interface SyncNamespaceTemplatesTaskParams {
+ spaceId: string;
+ packageName: string;
+ addedNamespaces: string[];
+ removedNamespaces: string[];
+}
+
+export function registerSyncNamespaceTemplatesTask(taskManagerSetup: TaskManagerSetupContract) {
+ taskManagerSetup.registerTaskDefinitions({
+ [TASK_TYPE]: {
+ title: 'Fleet Sync namespace templates',
+ // Most syncs finish in well under a minute, but a large integration with many
+ // data streams and existing backing indices can need longer due to mapping
+ // updates and rollovers. 15 minutes is a generous safety margin while still
+ // bounded; failures are retried up to maxAttempts.
+ timeout: '15m',
+ maxAttempts: 3,
+ createTaskRunner: ({ taskInstance, abortController }) => {
+ const { spaceId, packageName, addedNamespaces, removedNamespaces } =
+ taskInstance.params as SyncNamespaceTemplatesTaskParams;
+ return {
+ async run() {
+ if (addedNamespaces.length === 0 && removedNamespaces.length === 0) {
+ return;
+ }
+
+ const logger = appContextService.getLogger();
+ logger.debug(
+ `[syncNamespaceTemplatesTask] Running for package ${packageName} in space ${spaceId}: adding [${addedNamespaces}], removing [${removedNamespaces}]`
+ );
+
+ const soClient = appContextService.getInternalUserSOClientForSpaceId(spaceId);
+ const esClient = appContextService.getInternalUserESClient();
+
+ try {
+ await syncNamespaceTemplates({
+ soClient,
+ esClient,
+ packageName,
+ addedNamespaces,
+ removedNamespaces,
+ abortController,
+ });
+ } catch (err) {
+ logger.error(
+ `[syncNamespaceTemplatesTask] Failed for package ${packageName} in space ${spaceId}: ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ { error: err }
+ );
+ // Rethrow so task manager records the failure and retries up to maxAttempts.
+ throw err;
+ }
+ },
+ };
+ },
+ },
+ });
+}
+
+export async function scheduleSyncNamespaceTemplatesTask(
+ taskManagerStart: TaskManagerStartContract,
+ params: SyncNamespaceTemplatesTaskParams
+) {
+ if (params.addedNamespaces.length === 0 && params.removedNamespaces.length === 0) {
+ return;
+ }
+
+ await taskManagerStart.ensureScheduled({
+ id: `${TASK_TYPE}:${uuidv4()}`,
+ scope: ['fleet'],
+ params,
+ taskType: TASK_TYPE,
+ runAt: new Date(Date.now() + 3 * 1000),
+ state: {},
+ });
+}
diff --git a/x-pack/platform/plugins/shared/fleet/server/types/models/epm_packages.ts b/x-pack/platform/plugins/shared/fleet/server/types/models/epm_packages.ts
index 24cec4f485eff..944f079504c92 100644
--- a/x-pack/platform/plugins/shared/fleet/server/types/models/epm_packages.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/types/models/epm_packages.ts
@@ -87,6 +87,9 @@ export const EpmPackagesSchemaV8 = EpmPackagesSchemaV7.extends({
});
export const EpmPackagesSchemaV9 = EpmPackagesSchemaV8.extends({
+ namespace_customization_enabled_for: schema.maybe(
+ schema.arrayOf(schema.string(), { maxSize: 100 })
+ ),
previous_dependency_versions: schema.maybe(
schema.nullable(
schema.arrayOf(
diff --git a/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/epm.ts b/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/epm.ts
index 2d04f0933151b..5e1c721d2f688 100644
--- a/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/epm.ts
+++ b/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/epm.ts
@@ -681,7 +681,28 @@ export const GetBulkAssetsRequestSchema = {
export const UpdatePackageRequestSchema = {
params: PackageVersionRequestParamsSchema,
body: schema.object({
- keepPoliciesUpToDate: schema.boolean(),
+ keepPoliciesUpToDate: schema.maybe(schema.boolean()),
+ namespace_customization_enabled_for: schema.maybe(
+ schema.arrayOf(
+ schema.string({
+ validate: (v) => {
+ if (!v.length) {
+ return 'Must not be empty';
+ }
+ if (!/^[a-z0-9_]+$/.test(v)) {
+ return 'Must only contain lowercase letters, numbers, and underscores';
+ }
+ },
+ }),
+ {
+ maxSize: 100,
+ meta: {
+ description:
+ 'Namespaces for which namespace-level customization is enabled on this package.',
+ },
+ }
+ )
+ ),
}),
};
@@ -690,6 +711,66 @@ export const UpdatePackageWithoutVersionRequestSchema = {
body: UpdatePackageRequestSchema.body,
};
+export const BulkNamespaceCustomizationRequestSchema = {
+ body: schema.object({
+ packages: schema.arrayOf(schema.string(), {
+ minSize: 1,
+ maxSize: 1000,
+ meta: {
+ description: 'Package names to apply the customization changes to.',
+ },
+ }),
+ enable: schema.maybe(
+ schema.arrayOf(
+ schema.string({
+ validate: (v) => {
+ if (!v.length) {
+ return 'Must not be empty';
+ }
+ if (!/^[a-z0-9_]+$/.test(v)) {
+ return 'Must only contain lowercase letters, numbers, and underscores';
+ }
+ },
+ }),
+ {
+ maxSize: 100,
+ meta: {
+ description: 'Namespaces to enable namespace-level customization for on each package.',
+ },
+ }
+ )
+ ),
+ disable: schema.maybe(
+ schema.arrayOf(schema.string(), {
+ maxSize: 100,
+ meta: {
+ description: 'Namespaces to disable namespace-level customization for on each package.',
+ },
+ })
+ ),
+ }),
+};
+
+export const BulkNamespaceCustomizationResponseSchema = schema.object({
+ items: schema.arrayOf(
+ schema.object({
+ name: schema.string(),
+ success: schema.boolean(),
+ namespace_customization_enabled_for: schema.maybe(
+ schema.arrayOf(schema.string(), {
+ maxSize: 100,
+ meta: {
+ description:
+ 'The opt-in list on the package. Returned whenever the package is installed: the new list on success, or the unchanged list when the request is rejected (for example, because of a namespace-prefix restriction).',
+ },
+ })
+ ),
+ error: schema.maybe(schema.string()),
+ }),
+ { maxSize: 1000 }
+ ),
+});
+
export const ReviewUpgradeRequestSchema = {
params: schema.object({
pkgName: schema.string({
diff --git a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts
index 74d24907111c9..c69b97ca04591 100644
--- a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts
+++ b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts
@@ -214,6 +214,7 @@ export default function ({ getService }: FtrProviderContext) {
'fleet:setup',
'fleet:setup:upgrade_managed_package_policies',
'fleet:sync-integrations-task',
+ 'fleet:sync_namespace_templates',
'fleet:unenroll-inactive-agents-task',
'fleet:unenroll_action:retry',
'fleet:update_agent_tags:retry',