Skip to content

Commit 09187d5

Browse files
patrykkopycinskiclaudekibanamachine
authored
feat(cases): migrate endpoint attachment to unified v2 framework (elastic#260544)
## Summary - Migrates the endpoint case attachment from the legacy `ExternalReferenceAttachmentType` to the new unified `UnifiedReferenceAttachmentType` on both client and server. - New endpoint response-action attachments are written as `{ type: 'security.endpoint', attachmentId, metadata }` (`UnifiedReferenceAttachmentPayload`) instead of the legacy `externalReference` shape. - Adds server-side `io-ts` schema validation for endpoint attachment metadata (`command`, `comment`, `targets[]` with a closed union on `agentType`, unknown keys rejected, non-empty `targets` required). - Adds a generic `externalReference` ↔ unified transformer in the Cases plugin so pre-existing legacy endpoint attachments render as unified on read and unified writes fall back to legacy storage when the new SO type is disabled — no data migration required. ## Details Part of the [Cases Attachments v2 migration](elastic/security-team#15569). The endpoint attachment (historically `externalReferenceAttachmentTypeId: 'endpoint'`) is now registered as the unified type `SECURITY_ENDPOINT_ATTACHMENT_TYPE = 'security.endpoint'`, re-exported from `@kbn/cases-plugin/common`. ### What changed | Layer | Before | After | |-------|--------|-------| | Client registration (`security_solution/public/plugin.tsx`) | `registerExternalReference(getExternalReferenceAttachmentEndpointRegular())` | `registerUnified(getEndpointUnifiedAttachment())` | | Server registration (`security_solution/server/plugin.ts`) | `registerExternalReference({ id: CASE_ATTACHMENT_ENDPOINT_TYPE_ID })` | `registerUnified({ id: SECURITY_ENDPOINT_ATTACHMENT_TYPE, schemaValidator: validateEndpointAttachmentMetadata })` | | Attachment creation (`base_response_actions_client.ts`) | `{ type: 'externalReference', externalReferenceId, externalReferenceStorage, externalReferenceAttachmentTypeId: 'endpoint', externalReferenceMetadata }` | `{ type: 'security.endpoint', attachmentId, metadata, owner }` | | Metadata validation | none | `io-ts` validator run on the unified write path | | Client-side renderers | `external_reference.tsx` + 2 lazy wrappers | `unified_attachment.tsx` + updated `endpoint_event.tsx` / `endpoint_children.tsx` | | Constant `CASE_ATTACHMENT_ENDPOINT_TYPE_ID` | defined in Security Solution | removed; import `SECURITY_ENDPOINT_ATTACHMENT_TYPE` from `@kbn/cases-plugin/common` | ### Backward compatibility (no data migration needed) The legacy `registerExternalReference` calls are removed — BWC is delivered instead by a generic transformer in the Cases plugin: - **Read path** — the Kibana Cases UI reads cases via the internal `resolve` endpoint with `mode: 'unified'`. The new `externalReferenceAttachmentTransformer` in `x-pack/platform/plugins/shared/cases/server/common/attachments/external_reference.ts` converts any pre-existing legacy `externalReference` endpoint attachments stored in `cases-comments` into the unified `security.endpoint` shape on read, driven by `EXTERNAL_REFERENCE_TYPE_MAP`. Existing cases render identically post-deploy without any backfill. - **Write path** — when `xpack.cases.attachments.enabled` is `false` (default), the Cases plugin translates the unified payload back to the legacy `externalReference` shape via `toLegacySchema` and persists it to `cases-comments` — byte-for-byte equivalent to today's storage. When the flag is `true`, the unified payload is stored as-is in the new `cases-attachments` SO. Either way, the on-disk format stays consistent with whatever the deployment is already using. This also gives follow-up subtypes (e.g. `osquery`, other response-action types) a clean seam: add an entry to `EXTERNAL_REFERENCE_TYPE_MAP` / `UNIFIED_TO_EXTERNAL_REFERENCE_TYPE_MAP` and they get the same round-trip behaviour for free. ### Public case APIs `GET /api/cases/:id` (`totalComment`) and `GET /api/cases/:id/comments/_find` continue to be scoped to user-generated comments and do not surface endpoint attachments — this is pre-existing, intended behaviour and is unchanged by this PR. The Kibana UI uses the internal `resolve` endpoint which returns all attachment types and renders endpoint attachments via the new unified registry. ## Incremental fixes after first review A second pass addressed three review items from @szwarckonrad on the upgrade-path walkthrough, plus one CI-driven snapshot follow-up. None of them change the design described above; they harden the same migration against edge cases the first pass missed. 1. **Back-compat for legacy-shape API writes** (`security_solution/server/cases/attachments/register.ts` + `plugin.ts`) — the legacy `endpoint` external-reference id is registered alongside the new unified `security.endpoint` so existing API clients that still POST `{ type: 'externalReference', externalReferenceAttachmentTypeId: 'endpoint', ... }` are not rejected with `400 "Attachment type endpoint is not registered."`. The cases server's external-reference transformer already converts these legacy SOs to the unified shape on read; this restores the same behaviour for legacy-shape *writes*. Covered by a focused unit test (`register.test.ts`) that explicitly asserts the BWC registration so it can't be silently dropped in a future refactor. 2. **400 instead of 500 from the metadata validator** (`endpoint_metadata_schema.ts`) — `validateEndpointAttachmentMetadata` now throws `Boom.badRequest` on invalid metadata. Errors thrown from a registered cases-plugin `schemaValidator` callback are surfaced to the HTTP client as-is — a plain `Error` would have bubbled up as `500 Internal Server Error` with a stack trace in the server log for what is really a caller mistake. Covered by new tests asserting `Boom.isBoom` and `statusCode: 400` for null/non-object/missing-fields/empty-targets/unknown-keys inputs. 3. **Byte-clean legacy storage** (`cases/server/services/attachments/index.ts`) — when a unified payload (`{ type: 'security.endpoint', attachmentId, metadata }`) is POSTed but `xpack.cases.attachments.enabled` is OFF, the request attributes still carry those keys after `io-ts` decoding and could leak into `_source` (the `cases-comments` mapping is `dynamic: false`, so they would be stored but not indexed). The new `stripUnifiedOnlyFields` helper guarantees byte-for-byte equivalence with pre-migration legacy writes for `create`/`bulkCreate`/`update`/`bulkUpdate`. Covered by two regression tests in `services/attachments/index.test.ts`. 4. **Snapshot follow-up for #1** (`cases_api_integration/.../external_references.ts`) — the registry-snapshot assertion that guards the externalReference registry now expects `endpoint: 'e13fe41b5c330dd923da91992ed0cedb7e30960f'` again, with an inline comment explaining the BWC intent. **This file is owned by Response Ops via CODEOWNERS** — the snapshot's purpose is exactly to surface this kind of change for their review. CI on the latest push: green (build #437809, 428/428 jobs). ## Test plan - [x] Unit tests for the generic `externalReference` ↔ unified transformer, storage-type resolver, and type-routing helper (`x-pack/platform/plugins/shared/cases/server/common/attachments/*.test.ts`). - [x] Unit tests for `validateEndpointAttachmentMetadata` covering: valid metadata, each missing required field, empty `targets`, invalid `agentType`, unknown top-level keys, and non-object input. - [x] Updated `endpoint_actions_client` and `base_response_actions_client` unit tests assert the new unified payload shape (`type: 'security.endpoint'`, `attachmentId`, `metadata`). - [x] Updated unit tests for `endpoint_event.tsx` / `endpoint_children.tsx` against the unified props shape. - [x] Integration-test registry expectations updated: `security.endpoint` appears in `registered_unified_{basic,trial}.ts`. `endpoint` is **kept** in `external_references.ts` with a comment explaining the back-compat re-registration (see "Incremental fixes after first review" below). - [x] Type check and lint pass. - [x] Manual end-to-end validation against **Microsoft Defender for Endpoint** — unisolate action from Kibana correctly produces a `cases-attachments` SO of `type: 'security.endpoint'` with `microsoft_defender_endpoint` metadata, rendered by the UI via the unified registry. - [x] Manual end-to-end validation against **CrowdStrike Falcon** — unisolate action from Kibana correctly produces a `cases-attachments` SO of `type: 'security.endpoint'` with `crowdstrike` metadata, rendered by the UI via the unified registry. - [x] Manual verification: isolate a host via response actions and confirm the case attachment renders correctly. <img width="1798" height="1064" alt="CleanShot 2026-04-20 at 14 53 16@2x" src="https://github.com/user-attachments/assets/8c216722-a2a4-42ac-b5ae-dc8962cb2d0d" /> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent 7503cc9 commit 09187d5

35 files changed

Lines changed: 1402 additions & 247 deletions

File tree

x-pack/platform/plugins/shared/cases/common/constants/attachments.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@
44
* 2.0; you may not use this file except in compliance with the Elastic License
55
* 2.0.
66
*/
7+
// NOTE: Do not import from '../types/domain' here to avoid circular dependencies
8+
// (types/domain -> constants/index -> constants/attachments -> types/domain).
9+
// Use string literals for legacy type names instead.
10+
711
import { SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER, GENERAL_CASES_OWNER } from './owners';
812

913
// ----------------Unified attachment types-------------------------
1014
export const COMMENT_ATTACHMENT_TYPE = 'comment';
1115
export const SECURITY_EVENT_ATTACHMENT_TYPE = 'security.event';
16+
export const SECURITY_ENDPOINT_ATTACHMENT_TYPE = 'security.endpoint';
1217
export const LENS_ATTACHMENT_TYPE = 'lens';
1318

1419
export const ML_ANOMALY_SWIMLANE_ATTACHMENT_TYPE = 'ml.anomaly_swimlane';
@@ -34,6 +39,14 @@ export const LEGACY_AIOPS_CHANGE_POINT_CHART_ATTACHMENT_TYPE = 'aiopsChangePoint
3439
export const LEGACY_AIOPS_PATTERN_ANALYSIS_ATTACHMENT_TYPE = 'aiopsPatternAnalysisEmbeddable';
3540
export const LEGACY_AIOPS_LOG_RATE_ANALYSIS_ATTACHMENT_TYPE = 'aiopsLogRateAnalysisEmbeddable';
3641

42+
/**
43+
* Mapping from legacy externalReferenceAttachmentTypeId to unified type name.
44+
* Used by the generic externalReference transformer to resolve the unified type.
45+
*/
46+
export const EXTERNAL_REFERENCE_TYPE_MAP: Record<string, string> = {
47+
endpoint: SECURITY_ENDPOINT_ATTACHMENT_TYPE,
48+
} as const;
49+
3750
export const LEGACY_ATTACHMENT_TYPES = new Set([
3851
LEGACY_ACTIONS_TYPE,
3952
LEGACY_ALERT_TYPE,
@@ -46,6 +59,7 @@ export const LEGACY_ATTACHMENT_TYPES = new Set([
4659
export const UNIFIED_ATTACHMENT_TYPES = new Set([
4760
COMMENT_ATTACHMENT_TYPE,
4861
SECURITY_EVENT_ATTACHMENT_TYPE,
62+
SECURITY_ENDPOINT_ATTACHMENT_TYPE,
4963
]);
5064

5165
export const PERSISTABLE_STATE_LEGACY_TO_UNIFIED_MAP: Record<string, string> = {
@@ -84,6 +98,14 @@ export const LEGACY_TO_UNIFIED_MAP: Record<string, string> = {
8498
export const UNIFIED_TO_LEGACY_MAP: Record<string, string> = {
8599
[COMMENT_ATTACHMENT_TYPE]: LEGACY_USER_TYPE,
86100
[SECURITY_EVENT_ATTACHMENT_TYPE]: LEGACY_EVENT_TYPE,
101+
[SECURITY_ENDPOINT_ATTACHMENT_TYPE]: LEGACY_EXTERNAL_REFERENCE_TYPE,
102+
} as const;
103+
104+
/**
105+
* Reverse mapping from unified type name back to externalReferenceAttachmentTypeId.
106+
*/
107+
export const UNIFIED_TO_EXTERNAL_REFERENCE_TYPE_MAP: Record<string, string> = {
108+
[SECURITY_ENDPOINT_ATTACHMENT_TYPE]: 'endpoint',
87109
} as const;
88110

89111
/**
@@ -92,6 +114,7 @@ export const UNIFIED_TO_LEGACY_MAP: Record<string, string> = {
92114
export const MIGRATED_ATTACHMENT_TYPES = new Set<string>([
93115
COMMENT_ATTACHMENT_TYPE,
94116
SECURITY_EVENT_ATTACHMENT_TYPE,
117+
SECURITY_ENDPOINT_ATTACHMENT_TYPE,
95118
...PERSISTABLE_ATTACHMENT_TYPES,
96119
]);
97120

x-pack/platform/plugins/shared/cases/common/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export {
5959
CASES_REOPEN_CAPABILITY,
6060
ASSIGN_CASE_CAPABILITY,
6161
SECURITY_EVENT_ATTACHMENT_TYPE,
62+
SECURITY_ENDPOINT_ATTACHMENT_TYPE,
6263
MANAGE_TEMPLATES_CAPABILITY,
6364
ML_ANOMALY_SWIMLANE_ATTACHMENT_TYPE,
6465
ML_ANOMALY_CHARTS_ATTACHMENT_TYPE,
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { AttachmentType, ExternalReferenceStorageType } from '../../../common/types/domain';
9+
import { externalReferenceAttachmentTransformer } from './external_reference';
10+
11+
const baseLegacyAttributes = {
12+
created_at: '2024-01-01T00:00:00.000Z',
13+
created_by: {
14+
username: 'elastic',
15+
full_name: null,
16+
email: null,
17+
profile_uid: undefined,
18+
},
19+
pushed_at: null,
20+
pushed_by: null,
21+
updated_at: null,
22+
updated_by: null,
23+
};
24+
25+
const legacyEndpointAttributes = {
26+
...baseLegacyAttributes,
27+
type: AttachmentType.externalReference as const,
28+
externalReferenceId: 'action-123',
29+
externalReferenceStorage: {
30+
type: ExternalReferenceStorageType.elasticSearchDoc as const,
31+
},
32+
externalReferenceAttachmentTypeId: 'endpoint',
33+
externalReferenceMetadata: {
34+
command: 'isolate',
35+
comment: 'Test comment',
36+
targets: [{ endpointId: 'ep-1', hostname: 'host-1', agentType: 'endpoint' }],
37+
},
38+
owner: 'securitySolution',
39+
};
40+
41+
const unifiedEndpointAttributes = {
42+
...baseLegacyAttributes,
43+
type: 'security.endpoint',
44+
attachmentId: 'action-123',
45+
metadata: {
46+
command: 'isolate',
47+
comment: 'Test comment',
48+
targets: [{ endpointId: 'ep-1', hostname: 'host-1', agentType: 'endpoint' }],
49+
},
50+
owner: 'securitySolution',
51+
};
52+
53+
describe('externalReferenceAttachmentTransformer', () => {
54+
describe('toUnifiedSchema', () => {
55+
it('converts legacy external reference to unified format', () => {
56+
const result =
57+
externalReferenceAttachmentTransformer.toUnifiedSchema(legacyEndpointAttributes);
58+
expect(result).toEqual(
59+
expect.objectContaining({
60+
type: 'security.endpoint',
61+
attachmentId: 'action-123',
62+
metadata: legacyEndpointAttributes.externalReferenceMetadata,
63+
owner: 'securitySolution',
64+
})
65+
);
66+
});
67+
68+
it('passes through already-unified attributes', () => {
69+
const result =
70+
externalReferenceAttachmentTransformer.toUnifiedSchema(unifiedEndpointAttributes);
71+
expect(result).toEqual(unifiedEndpointAttributes);
72+
});
73+
});
74+
75+
describe('toLegacySchema', () => {
76+
it('converts unified format to legacy external reference', () => {
77+
const result =
78+
externalReferenceAttachmentTransformer.toLegacySchema(unifiedEndpointAttributes);
79+
expect(result).toEqual(
80+
expect.objectContaining({
81+
type: AttachmentType.externalReference,
82+
externalReferenceId: 'action-123',
83+
externalReferenceStorage: {
84+
type: ExternalReferenceStorageType.elasticSearchDoc,
85+
},
86+
externalReferenceAttachmentTypeId: 'endpoint',
87+
externalReferenceMetadata: unifiedEndpointAttributes.metadata,
88+
owner: 'securitySolution',
89+
})
90+
);
91+
});
92+
93+
it('passes through already-legacy attributes', () => {
94+
const result =
95+
externalReferenceAttachmentTransformer.toLegacySchema(legacyEndpointAttributes);
96+
expect(result).toEqual(legacyEndpointAttributes);
97+
});
98+
});
99+
100+
describe('type guards', () => {
101+
it('isType detects both legacy and unified', () => {
102+
expect(externalReferenceAttachmentTransformer.isType(legacyEndpointAttributes as never)).toBe(
103+
true
104+
);
105+
expect(
106+
externalReferenceAttachmentTransformer.isType(unifiedEndpointAttributes as never)
107+
).toBe(true);
108+
expect(
109+
externalReferenceAttachmentTransformer.isType({ type: AttachmentType.alert } as never)
110+
).toBe(false);
111+
});
112+
113+
it('does not match unmigrated external reference subtypes', () => {
114+
const unmigrated = {
115+
...legacyEndpointAttributes,
116+
externalReferenceAttachmentTypeId: 'some-unknown-type',
117+
};
118+
expect(externalReferenceAttachmentTransformer.isType(unmigrated as never)).toBe(false);
119+
});
120+
121+
it('isUnifiedType detects only unified', () => {
122+
expect(externalReferenceAttachmentTransformer.isUnifiedType(unifiedEndpointAttributes)).toBe(
123+
true
124+
);
125+
expect(externalReferenceAttachmentTransformer.isUnifiedType(legacyEndpointAttributes)).toBe(
126+
false
127+
);
128+
});
129+
130+
it('isLegacyType detects only legacy', () => {
131+
expect(externalReferenceAttachmentTransformer.isLegacyType(legacyEndpointAttributes)).toBe(
132+
true
133+
);
134+
expect(externalReferenceAttachmentTransformer.isLegacyType(unifiedEndpointAttributes)).toBe(
135+
false
136+
);
137+
});
138+
139+
it('isUnifiedType rejects payloads where attachmentId is not a single id string', () => {
140+
// External-reference unified attachments require `attachmentId: string`. The
141+
// shared `AttachmentAttributesV2` union allows `string | string[]` for alert/event
142+
// attachments, so the guard explicitly narrows to reject arrays coming via API.
143+
expect(
144+
externalReferenceAttachmentTransformer.isUnifiedType({
145+
...unifiedEndpointAttributes,
146+
attachmentId: ['a', 'b'],
147+
})
148+
).toBe(false);
149+
150+
expect(
151+
externalReferenceAttachmentTransformer.isUnifiedType({
152+
...unifiedEndpointAttributes,
153+
attachmentId: undefined,
154+
})
155+
).toBe(false);
156+
});
157+
});
158+
159+
describe('payload transforms', () => {
160+
const legacyPayload = {
161+
type: AttachmentType.externalReference as const,
162+
externalReferenceId: 'action-456',
163+
externalReferenceStorage: {
164+
type: ExternalReferenceStorageType.elasticSearchDoc as const,
165+
},
166+
externalReferenceAttachmentTypeId: 'endpoint',
167+
externalReferenceMetadata: {
168+
command: 'unisolate',
169+
comment: '',
170+
targets: [{ endpointId: 'ep-2', hostname: 'host-2', agentType: 'endpoint' }],
171+
},
172+
owner: 'securitySolution',
173+
};
174+
175+
const unifiedPayload = {
176+
type: 'security.endpoint',
177+
attachmentId: 'action-456',
178+
metadata: {
179+
command: 'unisolate',
180+
comment: '',
181+
targets: [{ endpointId: 'ep-2', hostname: 'host-2', agentType: 'endpoint' }],
182+
},
183+
owner: 'securitySolution',
184+
};
185+
186+
it('toUnifiedPayload converts legacy payload', () => {
187+
const result = externalReferenceAttachmentTransformer.toUnifiedPayload(legacyPayload);
188+
expect(result).toEqual(unifiedPayload);
189+
});
190+
191+
it('toUnifiedPayload throws for non-legacy payload', () => {
192+
expect(() => externalReferenceAttachmentTransformer.toUnifiedPayload(unifiedPayload)).toThrow(
193+
'Expected legacy external reference attachment payload'
194+
);
195+
});
196+
197+
it('toLegacyPayload converts unified payload', () => {
198+
const result = externalReferenceAttachmentTransformer.toLegacyPayload(unifiedPayload);
199+
expect(result).toEqual(legacyPayload);
200+
});
201+
202+
it('toLegacyPayload throws for non-unified payload', () => {
203+
expect(() => externalReferenceAttachmentTransformer.toLegacyPayload(legacyPayload)).toThrow(
204+
'Expected unified external reference attachment payload'
205+
);
206+
});
207+
208+
it('isLegacyPayload detects legacy payloads', () => {
209+
expect(externalReferenceAttachmentTransformer.isLegacyPayload(legacyPayload)).toBe(true);
210+
expect(externalReferenceAttachmentTransformer.isLegacyPayload(unifiedPayload)).toBe(false);
211+
});
212+
213+
it('isUnifiedPayload detects unified payloads', () => {
214+
expect(externalReferenceAttachmentTransformer.isUnifiedPayload(unifiedPayload)).toBe(true);
215+
expect(externalReferenceAttachmentTransformer.isUnifiedPayload(legacyPayload)).toBe(false);
216+
});
217+
});
218+
});

0 commit comments

Comments
 (0)