Skip to content

[Entity Store] Entity Unique identifier (EUID) translation layer#250951

Merged
romulets merged 24 commits intoelastic:mainfrom
romulets:entity-store/eui-translation-layer
Feb 6, 2026
Merged

[Entity Store] Entity Unique identifier (EUID) translation layer#250951
romulets merged 24 commits intoelastic:mainfrom
romulets:entity-store/eui-translation-layer

Conversation

@romulets
Copy link
Copy Markdown
Member

@romulets romulets commented Jan 29, 2026

Implements functions to support other teams usage of the Entity Unique Identifier EUID.

These are all naive implementations doing sequential checks. The current implementation allows teams to start using already. We might still tweak their logic to have less checks (e.g. have only 1 host.name check instead of 2 in the host definition).

How to use it
import { euid } from '@kbn/entity-store/common';

euid.getIdFromObject('host', doc);
euid.getEuidPainlessEvaluation('user');

Note 1: If for some reason those functions are needed in the front end, we might need to move to commons, but that will also require refactoring definitions to be under common. Possible, but let's do only if it's necessary. Done!

Note 2: We need to know the entity type to know what euid logic to follow. I'm not sure if there are cases where we don't know the entity type and we just want to find any id (the same document can return all ids). Then this logic will need to be handled on the consumer side (imo).

Added Functions

DSL getEuidDslFilterBasedOnDocument(entityType: EntityType, doc: any)

Based the entity id and on any document you have in memory (it can't be flattened, if that's a use case, we need to implement it) it returns a query DSL to be filtered on elasticsearch. For a full list of examples, see x-pack/solutions/security/plugins/entity_store/server/domain/euid/dsl.test.ts.

Simple example
const result = getEuidDslFilterBasedOnDocument('host', {
  host: { name: 'to-be-ignored', entity: { id: 'host-entity-1' } },
});
  
expect(result).toEqual({
  bool: {
    filter: [{ term: { 'host.entity.id': 'host-entity-1' } }],
  },
});
Example where it must match one field and other ones must not be present
const result = getEuidDslFilterBasedOnDocument('user', { user: { id: 'user-id-42' } });

expect(result).toEqual({
  bool: {
    filter: [{ term: { 'user.id': 'user-id-42' } }],
    must_not: [
      { exists: { field: 'user.entity.id' } },
      { exists: { field: 'user.name' } },
      { exists: { field: 'host.entity.id' } },
      { exists: { field: 'host.id' } },
      { exists: { field: 'host.name' } },
    ],
  },
});

ESQL getEuidEsqlFilterBasedOnDocument(entityType: EntityType, doc: any)

ESQL counterpart of the DSL filter. Same concept of not being available for flattened objects apply.

Simple example
const result = getEuidEsqlFilterBasedOnDocument('host', {
  host: { name: 'to-be-ignored', entity: { id: 'host-entity-1' } },
});

expect(result).toBe('((host.entity.id == "host-entity-1"))');
Example where it must match one field and other ones must not be present
const result = getEuidEsqlFilterBasedOnDocument('user', { user: { id: 'user-id-42' } });

expect(result).toBe(
        '((user.id == "user-id-42") AND (user.entity.id IS NULL OR user.entity.id == "") AND (user.name IS NULL OR user.name == "") AND (host.entity.id IS NULL OR host.entity.id == "") AND (host.id IS NULL OR host.id == "") AND (host.name IS NULL OR host.name == ""))'
      );

ESQL getEuidEsqlDocumentsContainsIdFilter(entityType: entityType)

Generates ESQL filter to make sure that we only have EUID Eligible documents in the context of the query. This makes for aids better performance before generating ids ESQL for every document available in the query. It's used in the main Entity Store Query.

Example of host

(host.entity.id IS NOT NULL AND host.entity.id != "") OR (host.id IS NOT NULL AND host.id != "") OR (host.name IS NOT NULL AND host.name != "") OR (host.hostname IS NOT NULL AND host.hostname != "")

ESQL getEuidEsqlEvaluation(entityType: EntityType)

Generates a ESQL EVAL expression which calculates the euid. It doesn't assign to any field so in your code you need to do it. E.g.

const query = `
  FROM logs-*
  | EVAL my_desired_id ${getEuidEsqlEvaluation('host')}
`;

It's used in the main Entity Store Query.

Example of eval for host
const result = getEuidEsqlEvaluation('host');

const expected = `CONCAT("host:", CASE((host.entity.id IS NOT NULL AND host.entity.id != ""), host.entity.id,
                    (host.id IS NOT NULL AND host.id != ""), host.id,
                    (host.name IS NOT NULL AND host.name != "" AND host.domain IS NOT NULL AND host.domain != ""), CONCAT(host.name, ".", host.domain),
                    (host.hostname IS NOT NULL AND host.hostname != "" AND host.domain IS NOT NULL AND host.domain != ""), CONCAT(host.hostname, ".", host.domain),
                    (host.name IS NOT NULL AND host.name != ""), host.name,
                    (host.hostname IS NOT NULL AND host.hostname != ""), host.hostname, NULL))`;
expect(normalize(result)).toBe(normalize(expected));

Typescript getEuidFromObject(entityType: EntityType, doc: any)

Based the entity id and on any document you have in memory (it can't be flattened, if that's a use case, we need to implement it) it returns an EUID. E.g.

expect(getEuidFromObject('host', { host: { name: 'myserver', domain: 'example.com' } })).toBe(
  'host:myserver.example.com'
);

Painless getEuidPainlessEvaluation(entityType: EntityType)

Generates a painless script for the EUID generation. It's based on the existence of Map doc and it returns null. It's hard to assume what will be available in the painless context or if we must return a value or emit a value, depending on where you use this script (runtime, aggregation, transform). What is nice is that a Map will be available in some shape and form.

For example, to use in a runtime field (tested with scout), you can wrap the generation around a function and emit the value:

function toRuntimeFieldEmitScript(entity: EntityType): string {
  return `String euid_eval(def doc) { ${getEuidPainlessEvaluation(entityType)} } 
    def result = euid_eval(doc); 
    if (result != null) {
       emit(result);
    }`;
}

Now looking at it, maybe we could be the ones providing it these context painless specific scripts. But the beauty of the current implementation is that users could use how they want, where they want, without depending on us to implement something new.

Example of generated painless script for host
if (
  doc.containsKey("host.entity.id") &&
  doc["host.entity.id"].size() > 0 &&
  doc["host.entity.id"].value != null &&
  doc["host.entity.id"].value != ""
) {
  return "host:" + doc["host.entity.id"].value;
}
if (
  doc.containsKey("host.id") &&
  doc["host.id"].size() > 0 &&
  doc["host.id"].value != null &&
  doc["host.id"].value != ""
) {
  return "host:" + doc["host.id"].value;
}
if (
  doc.containsKey("host.name") &&
  doc["host.name"].size() > 0 &&
  doc["host.name"].value != null &&
  doc["host.name"].value != "" &&
  doc.containsKey("host.domain") &&
  doc["host.domain"].size() > 0 &&
  doc["host.domain"].value != null &&
  doc["host.domain"].value != ""
) {
  return "host:" + doc["host.name"].value + "." + doc["host.domain"].value;
}
if (
  doc.containsKey("host.hostname") &&
  doc["host.hostname"].size() > 0 &&
  doc["host.hostname"].value != null &&
  doc["host.hostname"].value != "" &&
  doc.containsKey("host.domain") &&
  doc["host.domain"].size() > 0 &&
  doc["host.domain"].value != null &&
  doc["host.domain"].value != ""
) {
  return "host:" + doc["host.hostname"].value + "." + doc["host.domain"].value;
}
if (
  doc.containsKey("host.name") &&
  doc["host.name"].size() > 0 &&
  doc["host.name"].value != null &&
  doc["host.name"].value != ""
) {
  return "host:" + doc["host.name"].value;
}
if (
  doc.containsKey("host.hostname") &&
  doc["host.hostname"].size() > 0 &&
  doc["host.hostname"].value != null &&
  doc["host.hostname"].value != ""
) {
  return "host:" + doc["host.hostname"].value;
}
return null;

@romulets romulets linked an issue Jan 29, 2026 that may be closed by this pull request
@romulets romulets self-assigned this Jan 30, 2026
@hop-dev hop-dev requested a review from Copilot January 30, 2026 09:40
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a unified Entity Unique Identifier (EUID) abstraction across the entity store, with shared logic for computing, querying, and validating entity IDs in memory, ES|QL, Painless, and DSL, and wires it into both server-side code and Scout-based integration tests.

Changes:

  • Refactors entity identity definitions to use a generic euidFields description and requiresOneOfFields, and adds shared helpers for EUID computation in memory (getEuidFromObject), ES|QL (getEuidEsqlFilter / getEuidEsqlEvaluation), Painless (getEuidPainlessEvaluation), and DSL (getEuidDslFilterBasedOnDocument).
  • Adapts logs extraction ES|QL query generation to use the new EUID helpers and updates snapshots and test data/expectations accordingly.
  • Adds Scout API tests and fixtures to validate extraction, DSL/EUID round-trips, and Painless runtime-field behavior against the archived data.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/painless_translation.spec.ts New Scout tests verifying Painless runtime-field EUID computation matches in-memory getEuidFromObject for each entity type.
x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/entity_extraction.spec.ts Refactors entity extraction expectations into shared fixtures and updates expected counts/entities for host/user/service/generic.
x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts New Scout tests validating that the DSL produced by getEuidDslFilterBasedOnDocument selects the intended documents for each entity type.
x-pack/solutions/security/plugins/entity_store/test/scout/api/fixtures/entity_extraction_expected.ts Centralized expected entity documents for extraction tests across all entity types.
x-pack/solutions/security/plugins/entity_store/test/scout/api/fixtures/constants.ts Adds UPDATES_INDEX mapping for each EntityType to its updates data stream.
x-pack/solutions/security/plugins/entity_store/test/scout/api/es_archives/updates/data.json Adds an extra host update document with host.entity.id to exercise new EUID precedence.
x-pack/solutions/security/plugins/entity_store/server/index.ts Exposes a public euid helper API and re-exports EntityType for other plugins to consume without using the plugin start contract.
x-pack/solutions/security/plugins/entity_store/server/domain/logs_extraction/logs_extraction_query_builder.ts Switches ES
x-pack/solutions/security/plugins/entity_store/server/domain/logs_extraction/__snapshots__/logs_extraction_query_builder.test.ts.snap Updates Jest snapshots to reflect the new ES
x-pack/solutions/security/plugins/entity_store/server/domain/euid/painless.ts Implements Painless EUID script generation based on euidFields metadata.
x-pack/solutions/security/plugins/entity_store/server/domain/euid/painless.test.ts Snapshot tests for getEuidPainlessEvaluation per entity type.
x-pack/solutions/security/plugins/entity_store/server/domain/euid/memory.ts In-memory EUID computation (getEuidFromObject) and nested field accessor (getFieldValue) based on the new euidFields description.
x-pack/solutions/security/plugins/entity_store/server/domain/euid/memory.test.ts Unit tests validating precedence and fallbacks for getEuidFromObject across all entity types.
x-pack/solutions/security/plugins/entity_store/server/domain/euid/index.ts Barrel export for EUID helpers (memory, Painless, DSL, ES
x-pack/solutions/security/plugins/entity_store/server/domain/euid/esql.ts ES
x-pack/solutions/security/plugins/entity_store/server/domain/euid/esql.test.ts Tests for getEuidEsqlFilter and getEuidEsqlEvaluation (generic and host).
x-pack/solutions/security/plugins/entity_store/server/domain/euid/dsl.ts Builds a DSL filter from a sample document using euidFields and ranking, plus negative conditions for higher-precedence fields.
x-pack/solutions/security/plugins/entity_store/server/domain/euid/dsl.test.ts Tests for DSL filter generation for generic, host, user, and service, including precedence and “no EUID” cases.
x-pack/solutions/security/plugins/entity_store/server/domain/euid/__snapshots__/painless.test.ts.snap Snapshots for generated Painless EUID scripts.
x-pack/solutions/security/plugins/entity_store/server/domain/esql/strings.ts Loosens the type of esqlIsNotNullOrEmpty to accept optional field names for use with EuidAttribute.
x-pack/solutions/security/plugins/entity_store/server/domain/definitions/user.ts Migrates user identity definition to requiresOneOfFields/euidFields and adds retention config for user.entity.id.
x-pack/solutions/security/plugins/entity_store/server/domain/definitions/service.ts Migrates service identity definition to requiresOneOfFields/euidFields and adds retention config for service.entity.id.
x-pack/solutions/security/plugins/entity_store/server/domain/definitions/registry.ts Adds getEntityDefinitionWithoutId as a reusable accessor for identity logic.
x-pack/solutions/security/plugins/entity_store/server/domain/definitions/host.ts Migrates host identity definition to requiresOneOfFields/euidFields and adds retention config for host.entity.id.
x-pack/solutions/security/plugins/entity_store/server/domain/definitions/generic.ts Simplifies generic identity to euidFields and commentary around entity.id.
x-pack/solutions/security/plugins/entity_store/server/domain/definitions/entity_schema.ts Redefines the identity schema around euidFields and introduces EuidAttribute typing used across EUID helpers.

@romulets romulets marked this pull request as ready for review January 30, 2026 14:09
@romulets romulets requested a review from a team as a code owner January 30, 2026 14:09
@romulets romulets added backport:skip This PR does not require backporting release_note:skip Skip the PR/issue when compiling release notes labels Jan 30, 2026
Copy link
Copy Markdown
Contributor

@opauloh opauloh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @romulets

I like a lot the fact that the ranking logic is consolidated in one place 🚀

@romulets romulets requested a review from chennn1990 February 2, 2026 11:00
@romulets romulets added ci:cloud-deploy Create or update a Cloud deployment ci:project-deploy-security Create a Security Serverless Project ci:build-cloud-image ci:build-serverless-image labels Feb 2, 2026
@romulets romulets requested a review from jmcarlock February 2, 2026 14:55
@elasticmachine
Copy link
Copy Markdown
Contributor

elasticmachine commented Feb 6, 2026

⏳ Build in-progress

Failed CI Steps

Test Failures

  • [job] [logs] FTR Configs #55 / "before all" hook in "{root}"

History

cc @romulets

@romulets romulets merged commit 95f9374 into elastic:main Feb 6, 2026
17 checks passed
abhishekbhatia1710 added a commit to abhishekbhatia1710/kibana that referenced this pull request Mar 10, 2026
The Entity Store V2 EUID PR (elastic#250951) is merged. This migrates the
lead generation pipeline from V1 per-entity-type indices to V2's
unified index pattern (.entities.v2.latest.security_{namespace}).

- fetchAllEntityStoreRecords now queries a single V2 index instead
  of looping over separate user/host V1 indices
- entityRecordToLeadEntity falls back to entity.id (EUID) when
  entity.name is absent
- temporal_state_module uses V2 history snapshot pattern and filters
  by entity.type/entity.name instead of V1 entity-type-specific fields
- De-duplicated entityToKey by importing from shared utils
- Added unit tests for fetchAllEntityStoreRecords, entityRecordToLeadEntity,
  and getEntityStoreLatestIndex
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:skip This PR does not require backporting ci:build-cloud-image ci:build-serverless-image ci:cloud-deploy Create or update a Cloud deployment ci:project-deploy-security Create a Security Serverless Project release_note:skip Skip the PR/issue when compiling release notes v9.4.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Entity Store] Create translation layers for EUID

7 participants