Skip to content

Commit d6c70fe

Browse files
maxcoldqn895
authored andcommitted
[Entity Store] Use seed as related.user alias resolution target (elastic#280870)
## Summary Fixes related.user alias resolution target selection so the **seed IDP entity always becomes the resolution target**, instead of namespace-priority selection (`AD > Okta > Entra ID`). Previously, when Entra ID (or another lower-priority IDP) asserted cross-system aliases via `related.user`, Active Directory could still win as the golden/target entity. The seed is the data-driven authority because it is the IDP that declared the aliases. Resolves the "target-direction product confirm" open item from the QA report in elastic#275776. ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [x] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/docs/extend/kibana/contributing/workflow/how-we-use-github#release-notes) - [x] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. ### Identify risks **Behavior change in alias target direction (medium).** Existing related.user resolution groups that previously preferred AD via namespace priority will now prefer the asserting seed IDP. Mitigated by unit coverage for seed-vs-higher-priority-candidate, and scoped only to the default-off `related_user_alias_resolution` maintainer (email automated-resolution unchanged).
1 parent 0a16710 commit d6c70fe

3 files changed

Lines changed: 85 additions & 77 deletions

File tree

x-pack/solutions/security/plugins/entity_store/server/domain/resolution/rules/maintainers/related_user_alias_resolution/run.test.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,11 @@ describe('runRelatedUserAliasResolution', () => {
8787
search: jest.fn(),
8888
} as unknown as jest.Mocked<ElasticsearchClient>;
8989
cascadeLinkEntities = jest.fn().mockResolvedValue({
90-
linked: ['user:seed@example.com@entra_id'],
90+
linked: ['user:ad'],
9191
retargeted: [],
9292
skipped: [],
9393
cascadesBlocked: 0,
94-
target_id: 'user:ad',
94+
target_id: 'user:seed@example.com@entra_id',
9595
});
9696
resolutionClient = { cascadeLinkEntities } as unknown as ResolutionClient;
9797
});
@@ -125,10 +125,23 @@ describe('runRelatedUserAliasResolution', () => {
125125
}),
126126
})
127127
);
128-
expect(cascadeLinkEntities).toHaveBeenCalledWith('user:ad', ['user:seed@example.com@entra_id']);
128+
expect(cascadeLinkEntities).toHaveBeenCalledWith('user:seed@example.com@entra_id', ['user:ad']);
129129
expect(result.lastRun).toMatchObject({ seedsScanned: 1, linksCreated: 1 });
130130
});
131131

132+
it('uses the seed as the cascade target even when a candidate has higher namespace priority', async () => {
133+
esClient.search
134+
.mockResolvedValueOnce(createSearchResponse([createSeed()]) as never)
135+
.mockResolvedValueOnce(createSearchResponse([createSourceDoc(['T03KX1Z'])]) as never)
136+
.mockResolvedValueOnce(
137+
createSearchResponse([createCandidate('user:ad', 'active_directory')]) as never
138+
);
139+
140+
await runRelatedUserAliasResolution(createDeps({ esClient, resolutionClient }));
141+
142+
expect(cascadeLinkEntities).toHaveBeenCalledWith('user:seed@example.com@entra_id', ['user:ad']);
143+
});
144+
132145
it('drops okta manager values before candidate lookup', async () => {
133146
esClient.search
134147
.mockResolvedValueOnce(createSearchResponse([createSeed()]) as never)
@@ -263,11 +276,11 @@ describe('runRelatedUserAliasResolution', () => {
263276

264277
it('accumulates all unambiguous candidates for one seed into a single cascade', async () => {
265278
cascadeLinkEntities.mockResolvedValueOnce({
266-
linked: ['user:seed@example.com@entra_id', 'user:okta'],
279+
linked: ['user:ad', 'user:okta'],
267280
retargeted: [],
268281
skipped: [],
269282
cascadesBlocked: 0,
270-
target_id: 'user:ad',
283+
target_id: 'user:seed@example.com@entra_id',
271284
});
272285
esClient.search
273286
.mockResolvedValueOnce(createSearchResponse([createSeed()]) as never)
@@ -282,8 +295,8 @@ describe('runRelatedUserAliasResolution', () => {
282295
const result = await runRelatedUserAliasResolution(createDeps({ esClient, resolutionClient }));
283296

284297
expect(cascadeLinkEntities).toHaveBeenCalledTimes(1);
285-
expect(cascadeLinkEntities).toHaveBeenCalledWith('user:ad', [
286-
'user:seed@example.com@entra_id',
298+
expect(cascadeLinkEntities).toHaveBeenCalledWith('user:seed@example.com@entra_id', [
299+
'user:ad',
287300
'user:okta',
288301
]);
289302
expect(result.lastRun).toMatchObject({ linksCreated: 2 });
@@ -306,7 +319,7 @@ describe('runRelatedUserAliasResolution', () => {
306319
const result = await runRelatedUserAliasResolution(createDeps({ esClient, resolutionClient }));
307320

308321
expect(cascadeLinkEntities).toHaveBeenCalledTimes(1);
309-
expect(cascadeLinkEntities).toHaveBeenCalledWith('user:ad', ['user:seed@example.com@entra_id']);
322+
expect(cascadeLinkEntities).toHaveBeenCalledWith('user:seed@example.com@entra_id', ['user:ad']);
310323
expect(result.lastRun).toMatchObject({ skippedAmbiguous: 1, linksCreated: 1 });
311324
});
312325

@@ -329,7 +342,7 @@ describe('runRelatedUserAliasResolution', () => {
329342
const result = await runRelatedUserAliasResolution(createDeps({ esClient, resolutionClient }));
330343

331344
expect(cascadeLinkEntities).toHaveBeenCalledTimes(1);
332-
expect(cascadeLinkEntities).toHaveBeenCalledWith('user:ad', ['user:seed@example.com@entra_id']);
345+
expect(cascadeLinkEntities).toHaveBeenCalledWith('user:seed@example.com@entra_id', ['user:ad']);
333346
expect(result.lastRun).toMatchObject({ linksCreated: 1 });
334347
});
335348

x-pack/solutions/security/plugins/entity_store/server/domain/resolution/rules/maintainers/related_user_alias_resolution/run.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { Logger } from '@kbn/logging';
1010
import type { SortResults } from '@elastic/elasticsearch/lib/api/types';
1111
import { getLatestEntitiesIndexName } from '../../../../../../common';
1212
import type { ResolutionClient } from '../../..';
13-
import { NAMESPACE_PRIORITY, selectTarget } from '../../..';
13+
import { NAMESPACE_PRIORITY } from '../../..';
1414
import { getFieldValue } from '../../../../../../common/domain/euid/commons';
1515
import { ENTITY_ID_FIELD } from '../../../../../../common/domain/definitions/common_fields';
1616
import type { MaintainerTelemetryClient } from '../../../../../tasks/entity_maintainers/maintainer_telemetry_client';
@@ -29,7 +29,7 @@ const ENGINE_METADATA_TYPE_FIELD = 'entity.EngineMetadata.Type';
2929
const RESOLVED_TO_FIELD = 'entity.relationships.resolution.resolved_to';
3030
const ENTITY_NAMESPACE_FIELD = 'entity.namespace';
3131
const FIRST_SEEN_FIELD = 'entity.lifecycle.first_seen';
32-
// The alias resolution seeds and target priority intentionally use the same IDP namespace set.
32+
// Seed collection uses the same IDP namespace set as NAMESPACE_PRIORITY.
3333
const IDP_NAMESPACES = NAMESPACE_PRIORITY;
3434
const RIGHT_MATCH_FIELDS = ['user.id', 'user.name', 'user.email', 'user.full_name'] as const;
3535
const LOCAL_NAMESPACE = 'local';
@@ -217,9 +217,10 @@ const findCandidates = async ({
217217
*
218218
* For each unresolved IDP seed, read the seed's own entityanalytics source
219219
* record, clean its related.user values, collect unambiguous entity-store
220-
* candidates across all values, then link the whole group once by namespace
221-
* priority. The source read is EUID-confirmed so records that merely mention
222-
* the seed do not become alias-resolution input.
220+
* candidates across all values, then cascade-link those candidates onto the
221+
* seed (the asserting IDP is always the resolution target). The source read
222+
* is EUID-confirmed so records that merely mention the seed do not become
223+
* alias-resolution input.
223224
*/
224225
export async function runRelatedUserAliasResolution(
225226
deps: RunRelatedUserAliasResolutionDeps
@@ -287,21 +288,14 @@ export async function runRelatedUserAliasResolution(
287288
}
288289
}
289290

290-
const group = [seed, ...candidatesForSeed.values()];
291-
if (group.length < 2) {
291+
const candidates = [...candidatesForSeed.values()];
292+
if (candidates.length === 0) {
292293
continue;
293294
}
294295

295296
try {
296-
const target = selectTarget(group);
297-
const aliasIds = group
298-
.filter((entity) => entity.entityId !== target.entityId)
299-
.map((entity) => entity.entityId);
300-
if (aliasIds.length === 0) {
301-
continue;
302-
}
303-
304-
const result = await resolutionClient.cascadeLinkEntities(target.entityId, aliasIds);
297+
const aliasIds = candidates.map((entity) => entity.entityId);
298+
const result = await resolutionClient.cascadeLinkEntities(seed.entityId, aliasIds);
305299
lastRun.linksCreated += result.linked.length;
306300
lastRun.cascadeRetargeted += result.retargeted.length;
307301
lastRun.cascadesBlocked += result.cascadesBlocked;

x-pack/solutions/security/plugins/entity_store/test/scout/crud_and_resolution/api/tests/related_user_alias_resolution.spec.ts

Lines changed: 53 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ apiTest.describe(
106106
});
107107

108108
apiTest(
109-
'enables alias resolution, links related user candidate by priority target, then preserves link when disabled',
109+
'enables alias resolution, links related user candidate to the seed target, then preserves link when disabled',
110110
async ({ apiClient, esClient }) => {
111111
const seedId = 'user:seed@example.com@entra_id';
112112
const adId = 'user:T03KX1Z@active_directory';
@@ -140,7 +140,8 @@ apiTest.describe(
140140
await triggerMaintainerRun(apiClient, internalHeaders, 'automated-resolution', {
141141
sync: true,
142142
});
143-
await waitForResolution(esClient, seedId, adId);
143+
// Seed (asserting IDP) is always the resolution target.
144+
await waitForResolution(esClient, adId, seedId);
144145
}
145146
);
146147

@@ -167,7 +168,7 @@ apiTest.describe(
167168
}
168169
);
169170
expect(group.statusCode).toBe(200);
170-
expect(group.body.target.entity.id).toBe(adId);
171+
expect(group.body.target.entity.id).toBe(seedId);
171172
expect(group.body.group_size).toBe(2);
172173
}
173174
);
@@ -195,6 +196,8 @@ apiTest.describe(
195196
await triggerMaintainerRun(apiClient, internalHeaders, 'automated-resolution', {
196197
sync: true,
197198
});
199+
// Candidate would carry resolved_to if the disabled rule still linked.
200+
await assertNotResolved(esClient, disabledAdId);
198201
await assertNotResolved(esClient, disabledSeedId);
199202
}
200203
);
@@ -234,60 +237,58 @@ apiTest.describe(
234237
}
235238
);
236239

237-
apiTest(
238-
'cascades candidate aliases to the priority target',
239-
async ({ apiClient, esClient }) => {
240-
const seedId = 'user:cascade@example.com@entra_id';
241-
const adId = 'user:CASCADE_AD@active_directory';
242-
const oktaId = 'user:cascade-okta@okta';
243-
const oktaAliasId = 'user:cascade-okta-alias@okta';
240+
apiTest('cascades candidate aliases to the seed target', async ({ apiClient, esClient }) => {
241+
const seedId = 'user:cascade@example.com@entra_id';
242+
const adId = 'user:CASCADE_AD@active_directory';
243+
const oktaId = 'user:cascade-okta@okta';
244+
const oktaAliasId = 'user:cascade-okta-alias@okta';
244245

245-
await seedUserEntity(esClient, {
246-
entityId: seedId,
247-
namespace: 'entra_id',
248-
email: 'cascade@example.com',
249-
userName: 'cascade@example.com',
250-
});
251-
await seedUserEntity(esClient, {
252-
entityId: adId,
253-
namespace: 'active_directory',
254-
email: 'cascade-ad@example.com',
255-
userName: 'CASCADE_AD',
256-
});
257-
await seedUserEntity(esClient, {
258-
entityId: oktaId,
259-
namespace: 'okta',
260-
email: 'cascade-okta@example.com',
261-
userName: 'cascade-okta-login',
262-
});
263-
await seedUserEntity(esClient, {
264-
entityId: oktaAliasId,
265-
namespace: 'okta',
266-
email: 'cascade-okta-alias@example.com',
267-
userName: 'cascade-okta-alias',
268-
});
246+
await seedUserEntity(esClient, {
247+
entityId: seedId,
248+
namespace: 'entra_id',
249+
email: 'cascade@example.com',
250+
userName: 'cascade@example.com',
251+
});
252+
await seedUserEntity(esClient, {
253+
entityId: adId,
254+
namespace: 'active_directory',
255+
email: 'cascade-ad@example.com',
256+
userName: 'CASCADE_AD',
257+
});
258+
await seedUserEntity(esClient, {
259+
entityId: oktaId,
260+
namespace: 'okta',
261+
email: 'cascade-okta@example.com',
262+
userName: 'cascade-okta-login',
263+
});
264+
await seedUserEntity(esClient, {
265+
entityId: oktaAliasId,
266+
namespace: 'okta',
267+
email: 'cascade-okta-alias@example.com',
268+
userName: 'cascade-okta-alias',
269+
});
269270

270-
const preLink = await apiClient.post(ENTITY_STORE_ROUTES.public.RESOLUTION_LINK, {
271-
headers: defaultHeaders,
272-
responseType: 'json',
273-
body: { target_id: oktaId, entity_ids: [oktaAliasId] },
274-
});
275-
expect(preLink.statusCode).toBe(200);
271+
const preLink = await apiClient.post(ENTITY_STORE_ROUTES.public.RESOLUTION_LINK, {
272+
headers: defaultHeaders,
273+
responseType: 'json',
274+
body: { target_id: oktaId, entity_ids: [oktaAliasId] },
275+
});
276+
expect(preLink.statusCode).toBe(200);
276277

277-
await seedEntityAnalyticsSource(esClient, {
278-
email: 'cascade@example.com',
279-
relatedUsers: ['CASCADE_AD', 'cascade-okta-login'],
280-
});
278+
await seedEntityAnalyticsSource(esClient, {
279+
email: 'cascade@example.com',
280+
relatedUsers: ['CASCADE_AD', 'cascade-okta-login'],
281+
});
281282

282-
await triggerMaintainerRun(apiClient, internalHeaders, 'automated-resolution', {
283-
sync: true,
284-
});
283+
await triggerMaintainerRun(apiClient, internalHeaders, 'automated-resolution', {
284+
sync: true,
285+
});
285286

286-
await waitForResolution(esClient, seedId, adId);
287-
await waitForResolution(esClient, oktaId, adId);
288-
await waitForResolution(esClient, oktaAliasId, adId);
289-
}
290-
);
287+
// Seed is the cascade target; AD and the pre-linked Okta group retarget onto it.
288+
await waitForResolution(esClient, adId, seedId);
289+
await waitForResolution(esClient, oktaId, seedId);
290+
await waitForResolution(esClient, oktaAliasId, seedId);
291+
});
291292
}
292293
);
293294

0 commit comments

Comments
 (0)