Skip to content

Decompose CollectionDAO & EntityRepository; fix lineage/usage/isBot bugs#28778

Open
harshach wants to merge 15 commits into
mainfrom
harshach/port-louis
Open

Decompose CollectionDAO & EntityRepository; fix lineage/usage/isBot bugs#28778
harshach wants to merge 15 commits into
mainfrom
harshach/port-louis

Conversation

@harshach
Copy link
Copy Markdown
Collaborator

@harshach harshach commented Jun 6, 2026

Describe your changes:

Fixes #28785

I decomposed the two backend "god-classes" — CollectionDAO (15,040 → 433 lines) and EntityRepository (12,797 → ~9,950 lines) — into cohesive, single-responsibility collaborators with no behavior change, and fixed several DAO-layer perf/correctness bugs found along the way. CollectionDAO is now a thin composite that extends 17 same-package domain aggregator interfaces (composite-interface-inheritance — zero call-site change), and EntityRepository delegates to 7 extracted collaborators while keeping its public/protected API intact for all 81 subclasses. The whole openmetadata-service test suite passes (5,800 tests, 0 failures), and a new CollectionDAOCompositionTest guards the JDBI wiring of the inherited @CreateSqlObject accessors. Each extraction was validated against the full suite, one collaborator at a time.

Type of change:

  • Improvement
  • Bug fix

High-level design:

CollectionDAO split (17 interfaces): RdfInfraDAOs, SearchReindexDAOs, AiGovernanceDAOs, FeedDAOs, ClassificationTagDAOs, TimeSeriesDAOs, ActivityAuditDAOs, GovernanceDAOs, EventSubscriptionDAOs, KnowledgeAssetDAOs, SystemTokenDAOs, DataAssetServiceDAOs, EntityDataDAOs, AccessControlDAOs, WorkflowDocStoreDAOs, OAuthDAOs, CoreRelationshipDAOs. JDBI's SqlObjectFactory discovers inherited @CreateSqlObject accessors via getMethods(), so daoCollection.xDAO() and CollectionDAO.X references resolve unchanged; only member-type import statements were repointed to the new declaring interface (compiler-enforced).

EntityRepository collaborators (7): EntityTaskWorkflows, EntityCacheLoaders, EntityCaches, EntityCacheInvalidator, InheritanceParentCache, BulkFieldFetcher, BulkImportService. Each holds a back-reference to the repository where needed and is reached via thin delegators, preserving overridable hooks and the protected API for the 62 EntityUpdater subclasses / 81 repositories. EntityUpdater itself was intentionally left in place (its 62-subclass inheritance coupling warrants a separate, dedicated refactor).

Bug/perf fixes: lineage findTo/FromPipeline operator-precedence bug (the relation filter was being dropped); entity_usage(entityType, usageDate) index for UsageDAO.computePercentile; Postgres isBot generated column was reading $.deleted instead of $.isBot (bots were counted as daily-active users); getMaxLastActivityTime/user-list isBot filters now use indexed columns; removed dead, malformed EntityExtensionDAO.update. Migrations are idempotent and live under bootstrap/sql/migrations/native/2.0.0/{mysql,postgres}/schemaChanges.sql.

Backward-compat: no public API or call-site changes; cache fields/inheritance behavior preserved via delegation; DB migrations are IF NOT EXISTS/guarded and validated on MySQL 8.0 + Postgres 16.

Tests:

Unit tests

  • Added CollectionDAOCompositionTest (verifies every moved @CreateSqlObject accessor stays visible + annotated — the mechanism JDBI relies on at runtime).

Backend integration tests

  • Not applicable — no API contract changes; existing openmetadata-service suite (5,800 tests) passes green and exercises the refactored DAO/repository layer end-to-end.

Ingestion / Playwright tests

  • Not applicable (no ingestion or UI changes).

Manual testing performed

  • Validated both DB migrations on ephemeral MySQL 8.0 and Postgres 16 containers: index creation is idempotent and EXPLAIN confirms the percentile query now does an index-only scan; the Postgres isBot fix flips a bot from counted→excluded in countDailyActiveUsers.

UI screen recording / screenshots:

Not applicable.

Checklist:

  • I have read the CONTRIBUTING document.
  • My PR is linked to a GitHub issue via Fixes #28785 above.
  • For JSON Schema changes: not applicable; DB migrations added for the entity_usage index and Postgres isBot column fix.
  • I have added tests and listed them above.

🤖 Generated with Claude Code

…e/usage/isBot bugs

Decompose the two backend god-classes into cohesive, single-responsibility
collaborators (behavior-preserving) and fix several DAO-layer perf/correctness bugs.

CollectionDAO (15,040 -> 433 lines): split into 17 same-package domain aggregator
interfaces it `extends` (composite-interface-inheritance, zero call-site change):
RdfInfraDAOs, SearchReindexDAOs, AiGovernanceDAOs, FeedDAOs, ClassificationTagDAOs,
TimeSeriesDAOs, ActivityAuditDAOs, GovernanceDAOs, EventSubscriptionDAOs,
KnowledgeAssetDAOs, SystemTokenDAOs, DataAssetServiceDAOs, EntityDataDAOs,
AccessControlDAOs, WorkflowDocStoreDAOs, OAuthDAOs, CoreRelationshipDAOs.
Added CollectionDAOCompositionTest guarding the JDBI wiring of inherited accessors.

EntityRepository (12,797 -> ~9,950 lines): extracted 7 collaborators with delegators
preserving the public/protected API for 81 subclasses: EntityTaskWorkflows,
EntityCacheLoaders, EntityCaches, EntityCacheInvalidator, InheritanceParentCache,
BulkFieldFetcher, BulkImportService.

Perf/correctness fixes:
- Lineage findTo/FromPipeline operator-precedence bug (restores the relation filter)
- entity_usage(entityType,usageDate) index for UsageDAO.computePercentile (2.0.0 migration)
- Postgres isBot generated column read $.deleted instead of $.isBot (2.0.0 migration);
  bots were counted as daily-active users on Postgres
- getMaxLastActivityTime / user-list isBot filters use the indexed columns
- deleted dead, malformed EntityExtensionDAO.update

Full openmetadata-service test suite passes (5,800 tests, 0 failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 6, 2026

✅ PR checks passed

The linked issue has a description and all required Shipping project fields set. Thanks!

@github-actions github-actions Bot added backend safe to test Add this label to run secure Github workflows on PRs labels Jun 6, 2026
Comment thread bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql
Comment thread bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql
harshach and others added 6 commits June 6, 2026 16:01
Resolve CollectionDAO.java conflict from the CollectionDAO split refactor:

- Port #28752 (chunk IN-list batch queries under the DB parameter ceiling)
  onto the moved DAOs: EntityExtensionDAO/EntityRelationshipDAO now live in
  CoreRelationshipDAOs; TagUsageDAO in ClassificationTagDAOs; UsageDAO in
  AccessControlDAOs; DataQualityDataTimeSeriesDAO/TestCaseResultTimeSeriesDAO
  in TimeSeriesDAOs. EntityDAO.queryInChunks/updateInChunks (auto-merged)
  drive the chunking; F1 dead-code delete and P1 lineage parenthesization
  preserved in EntityExtensionDAO/EntityRelationshipDAO.
- Add IntakeFormDAO from #27600 (ODPS/Custom Forms) to the composite
  CollectionDAO (accessor + interface + IntakeForm import).
- Redirect InListChunkingTest imports to the new declaring interfaces.

Service module compiles (main + test); InListChunkingTest (16) and
CollectionDAOCompositionTest (2) pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nup (E1)

A3 — getContractByEntityId full-scanned data_contract_entity filtering nested
$.entity.id / $.entity.type via JSON_EXTRACT on the per-entity contract
enforcement write path. Add VIRTUAL (MySQL) / STORED (Postgres) generated
columns contractEntityId / contractEntityType + a composite index, and rewrite
the query to seek on those columns plus the existing indexed `deleted` column.
Validated on MySQL 8.0 + Postgres 16: generated columns populate, query returns
only non-deleted matches, EXPLAIN shows an index seek (ref / Index Scan), and
the guarded migration is idempotent.

E1 — TagUsageCleanup.performCleanup walked the full tag_usage table with
offset += batchSize (O(n^2) deep-offset). Switch to keyset pagination on the
unique (source, tagFQNHash, targetFQNHash) key. Per-engine SQL: MySQL needs the
expanded-OR form (its optimizer makes it an index range seek; a row-constructor
comparison scans the whole index from the start — verified Handler_read_next),
Postgres seeks optimally with the row-constructor (Index Only Scan). Validated
on both engines: a batchSize=7 drive over 300 rows covers all 300 exactly once
with zero duplicates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (C1)

FieldRelationshipDAO was the one high-volume relationship DAO with no batch
insert, so FeedRepository.storeMentions and TaskRepository.storeMentions fired
one round trip per distinct mention. Add insertMany(List<FieldRelationship>)
mirroring EntityRelationshipDAO.bulkInsertTo: a @BindBeanList multi-row VALUES
with INSERT IGNORE (MySQL) / ON CONFLICT (fromFQNHash, toFQNHash, relation) DO
NOTHING (Postgres), omitting the nullable json column so no per-row ::jsonb cast
is needed. Both mention callers now build the bean list (pre-hashing FQNs via
FullyQualifiedName.buildHash — the same hash @BindFQN applies) and issue one
insertMany; empty lists are guarded so the VALUES clause is never empty.

Pipeline task-owner inserts are intentionally left on the single-row path: they
pass buildHash(fqn) into a @BindFQN param (a separate double-hash concern), so a
bean-bound batch would change the stored hash — out of scope here.

Validated on MySQL 8.0 + Postgres 16: a multi-row batch inserts all distinct
rows, a follow-up batch with duplicate keys is ignored with no error, and json
stays NULL — matching the single-row insert semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… 2 finish)

The CollectionDAO split left ~12 cross-domain row/mapper carriers (system
entity/service counts + pipeline reporting trends/metrics) inside the composite,
referenced as CollectionDAO.X from the aggregators and PipelineRepository — the
last thing keeping CollectionDAO from being a thin accessor list. Move them into
a new leaf interface SharedRowMappers and redirect all 21 references to their
canonical SharedRowMappers.X name (no CollectionDAO dependency, breaking the
aggregator->CollectionDAO type cycle).

CollectionDAO drops 470 -> 150 lines (now just the extends clause, 4 accessors,
IntakeFormDAO, AssetDAO). Pure type relocation, no behavior/SQL change; compiles
main+test, CollectionDAOCompositionTest passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…CollectionDAO/EntityRepository split

The CollectionDAO/EntityRepository decomposition moved member types into
aggregator interfaces and static caches into EntityCaches, but the redirect
never covered the openmetadata-integration-tests module, breaking its compile:

- SearchIndexRetryQueueIT imported CollectionDAO.SearchIndexRetryQueueDAO; that
  type now lives in SearchReindexDAOs, and a single-type import requires the
  canonical declaring interface (inherited member types can't be imported via a
  subtype). Repointed both imports to SearchReindexDAOs.
- EntityCacheInvalidationIT referenced EntityRepository.CACHE_WITH_ID; that field
  moved to EntityCaches (static fields aren't inherited via the subclass name).

The remaining "cannot find symbol: log" errors CI reported were Lombok cascade
fallout from these two — they clear once the real errors are fixed. Integration
-tests module now compiles (BUILD SUCCESS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…llow-up)

An adversarial audit (proven on a live MySQL container) found the E1 keyset
cleanup could silently skip rows: tagFQNHash/targetFQNHash are nullable, and once
the cursor lands on a NULL-hash row (MySQL sorts NULLs first, so this is reached
early), every subsequent `tagFQNHash > NULL` / `= NULL` / row-constructor compare
is UNKNOWN, the batch returns empty, and the scan terminates — skipping all
trailing rows. The two engine forms also diverge on NULL ordering.

Fix: exclude NULL hashes from the keyset scan (AND tagFQNHash IS NOT NULL AND
targetFQNHash IS NOT NULL) so the cursor only ever holds real keys. This keeps the
index seek (verified: MySQL type=range, Postgres Index Only Scan) and makes both
engines scan an identical row set regardless of their NULL ordering. The rare
malformed NULL-hash rows (no write path produces them) are swept once via a new
bounded getTagUsagesWithNullHash query so the cleanup tool still sees them.

Validated on MySQL 8.0 + Postgres 16: with NULL rows present, a batchSize=2 walk
now covers all non-NULL rows (0 dropped, 0 dupes) and the NULL sweep catches the
malformed rows; the pre-fix walk terminated early.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mPropertyValidator

An adversarial architecture review of the remaining EntityRepository clusters found
the big ones (EntityUpdater, inheritance/lifecycle template hooks, CSV stubs) must
stay — they are polymorphic seams 20-63 subclasses override, and extracting them
would force pervasive r.hook() callbacks. The one genuinely clean, callback-free
win all three reviewers named independently: the static extension/custom-property
validators (~210 lines), which carry zero entity-instance state and whose siblings
(validateCustomPropertyEntityReference[List]) already live in EntityUtil.

Move validateExtension(Object,String), validateAndTransformExtension,
validateHyperlinkUrl, getFormattedDateTimeField, validateTableType, validateEnumKeys
to a new org.openmetadata.service.util.CustomPropertyValidator. EntityRepository keeps
the instance validateExtension(T,boolean) create/update hook, now delegating to it.
Redirected the two external callers (ColumnRepository, EntityCsv) and dropped the two
now-unused static imports. EntityRepository -213 lines; pure move, no behavior change.

Service (main+test) and integration-tests modules both compile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
harshach and others added 2 commits June 6, 2026 19:07
… (A9)

deleteReportDataTypeAtDate's Postgres form recomputed
DATE(TO_TIMESTAMP((json->>'timestamp')::bigint/1000)) per row — non-sargable, a
full seq scan across all report types. Replace both engine forms with a single
half-open range over the indexed `timestamp` column:
  WHERE entityFQNHash = :reportDataType AND timestamp >= :startTs AND timestamp < :endTs
The caller derives [startTs, endTs) from the yyyy-MM-dd date in UTC via the existing
TimestampUtils helpers — matching how the date string and the Elasticsearch cleanup
(doc['timestamp'].toLocalDate()) are already built, so semantics are preserved while
the query becomes identical and index-backed on both engines.

Container-validated on MySQL 8.0 (EXPLAIN type=range) and Postgres 16 (Index Scan
using idx_report_data_ts_keyset): exactly the target UTC day is deleted; the
preceding-day-23:59, next-day-00:00, and other-entity boundary rows are preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ers (B4)

The bulk field fetchers batched their relationship records but then resolved each
EntityReference one row at a time via Entity.getEntityReferenceById — an N+1 over a
page of users/teams (teams, roles, personas, default/inherited personas, domains;
team users, defaultRoles, defaultPersona, parents, policies).

Add a shared EntityRepository.batchResolveRefs(entityType, ids) that resolves a
homogeneous id set in one getEntityReferencesByIds call and returns an id->ref map,
and route all 13 single-type fetchers through it. The mixed-type owns/follows
fetchers keep their per-row resolution: they resolve heterogeneous entity types with
defensive per-row try/catch for dangling owned/followed entities, which a batched
find() (throws on the first missing id) would regress.

Full service suite green (5,892 tests, 0 failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve 2 conflicts from the CollectionDAO/EntityRepository split meeting two
upstream fixes:

- #28771 (server audit entries to audit.log) changed the audit-log DAO insert to
  return int; that DAO moved to ActivityAuditDAOs in this branch, so ported the
  void->int signature there (AuditLogRepository now reads the affected-row count).
  Took the restructured CollectionDAO (--ours).
- #28720 (lost-update race fix in PolicyConditionUpdater) rewrote the inline
  rewrite loop into rewriteMatchingPolicies/rewriteSinglePolicy/casWritePolicy with
  a FOR UPDATE + CAS retry. Took the upstream rewrite and re-applied this branch's
  cache-invalidation redirect (EntityRepository.invalidateCacheForEntity ->
  EntityCacheInvalidator.invalidateCacheForEntity) inside casWritePolicy; kept both
  the EntityDAO (upstream) and EntityCacheInvalidator (ours) imports.

Service (main+test) and integration-tests compile; CollectionDAOCompositionTest,
AuditLogRepositoryTest (3), and PolicyConditionUpdaterTest (41) pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…broke fresh installs

CI integration tests (MySQL: 220 errors, Python ometa: 8 failures) failed with
"Unknown column 'contractEntityId'" from DataContractDAO.getContractByEntityId.
The A3 optimization rewrote that query to read VIRTUAL/STORED generated columns
added by the 2.0.0 migration, but the columns are absent in the fresh-install /
test-provisioned schema (the MySQL guarded ADD COLUMN did not materialize them),
so every getContractByEntityId call — including BaseEntityIT.get_entityDataContract_200
run for every entity type — threw a SQL syntax error.

Restore the original JSON-path query (JSON_EXTRACT / json#>>'{...}') that works on
any schema, and drop the generated-column + index migration blocks from both
2.0.0 schemaChanges.sql files. data_contract_entity is a small write-path table,
so the lost index seek is negligible; correctness on fresh installs matters more.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ions

deleteLineage and deleteLineageByFQN emit an ENTITY_LINEAGE_DELETED change event,
but deleteLineageBySource (the DELETE .../lineage/{entityType}/{entityId}/{lineageSource}
endpoint, used to drop all pipeline/OpenLineage edges into an entity) deleted the
relations without emitting any event, so consumers/alerts subscribed to lineage-deleted
events silently missed source-based bulk deletions.

Emit ENTITY_LINEAGE_DELETED for every removed relation (reusing the already-loaded
relations and resolveRefForCacheInvalidation), threading the authenticated principal
through as deletedBy from the resource endpoint.

Addresses PR #28778 review finding #2. Service main+test compile; LineageRepositoryTest
(18) passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gitar-bot
Copy link
Copy Markdown

gitar-bot Bot commented Jun 7, 2026

Code Review ⚠️ Changes requested 4 resolved / 5 findings

Decomposes CollectionDAO and EntityRepository into cohesive domain-specific collaborators while fixing several persistence bugs. Access control on lineage audit fields requires hardening, as the new implementation incorrectly trusts client-provided audit timestamps and user IDs.

⚠️ Security: Lineage audit fields createdBy/updatedBy now trust client payload

📄 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LineageRepository.java:1752-1766 📄 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LineageRepository.java:169

The refactor of addLineage replaced the previously authoritative audit-field assignment with applyTemporalFields, which now derives createdBy, updatedBy, createdAt and updatedAt from the incoming request body when no prior record exists.

Previously the code unconditionally did:

lineageDetails.setCreatedBy(updatedBy);
lineageDetails.setUpdatedBy(updatedBy);
lineageDetails.setCreatedAt(currentTime);
lineageDetails.setUpdatedAt(System.currentTimeMillis());

so these fields always reflected the authenticated principal (updatedBy) and server time.

Now:

  • resolveCreatedBy returns incoming.getCreatedBy() when there is no prior record.
  • applyTemporalFields sets updatedBy to incoming.getUpdatedBy() != null ? incoming.getUpdatedBy() : fallbackUser — i.e. a client-supplied updatedBy in the LineageDetails request body overrides the authenticated user.
  • resolveUpdatedAt/resolveCreatedAt likewise honor client-supplied timestamps.

Because lineageDetails comes directly from the AddLineage request body (see addLineage), a caller can spoof the provenance/audit fields (who created/updated the lineage edge and when), and these spoofed values are persisted and propagated into the emitted ENTITY_LINEAGE_ADDED/UPDATED change events. This is an audit-integrity regression.

Suggested fix: keep the prior-record preservation behavior for consolidation, but always force updatedBy to the authenticated fallbackUser, and ignore client-supplied createdBy on initial creation (use fallbackUser). Timestamps should be server-generated.

Force updatedBy/updatedAt to the authenticated user and server time; only preserve the original createdBy from the prior record.
private static void applyTemporalFields(
    LineageDetails incoming, LineageDetails prior, String fallbackUser, long now) {
  incoming.setCreatedAt(resolveCreatedAt(incoming, prior, now));
  // Provenance must reflect the authenticated principal, never client-supplied values.
  incoming.setCreatedBy(prior != null && prior.getCreatedBy() != null ? prior.getCreatedBy() : fallbackUser);
  incoming.setUpdatedAt(now);
  incoming.setUpdatedBy(fallbackUser);
}
✅ 4 resolved
Bug: isBot fix only on Postgres; confirm MySQL was already correct

📄 bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql:341-352
The PR fixes the Postgres isBot generated column (migration 1.6.3 erroneously defined it as (json ->> 'deleted')::boolean). I verified that the corresponding MySQL migration (1.6.3) defined the column correctly as GENERATED ALWAYS AS (json -> '$.isBot'), so MySQL did NOT have the bug and correctly does not receive an equivalent fix in 2.0.0/mysql/schemaChanges.sql. This is consistent and correct — flagging only as a verification note so reviewers confirm no MySQL deployment was relying on the buggy behavior. No code change needed.

Quality: Composition guard test only checks a hardcoded subset of moved accessors

📄 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/CollectionDAOCompositionTest.java:35-49
CollectionDAOCompositionTest guards that @CreateSqlObject accessors moved into the new aggregator interfaces stay visible/annotated through CollectionDAO — the mechanism JDBI relies on at runtime. However it only validates the ~35 entries hardcoded in MOVED_ACCESSOR_TO_INTERFACE, not every moved accessor across all 17 interfaces. A regression where a non-listed accessor loses its annotation or visibility would not be caught. Consider deriving the expected accessor set by reflecting over the extended interfaces (iterate the extends list and collect their @CreateSqlObject methods) so the test stays exhaustive as the interfaces evolve.

Performance: Postgres isBot migration rewrites user_entity table under exclusive lock

📄 bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql:347-352
The Postgres fix does ALTER TABLE user_entity DROP COLUMN IF EXISTS isBot followed by ADD COLUMN isBot BOOLEAN GENERATED ALWAYS AS ((json ->> 'isBot')::boolean) STORED NOT NULL. Adding a STORED generated column forces a full table rewrite and takes an ACCESS EXCLUSIVE lock on user_entity for the duration, in addition to the preceding full-table UPDATE ... jsonb_set(...). On deployments with very large user_entity tables this can mean a notable maintenance-window stall. The logic is correct and idempotent; this is purely an operational heads-up. Consider documenting expected runtime / running during a maintenance window, which the PR's migration notes partially do.

Bug: deleteLineageBySource omits ENTITY_LINEAGE_DELETED change events

📄 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LineageRepository.java:1093-1107
Both deleteLineage and deleteLineageByFQN were updated to emit an ENTITY_LINEAGE_DELETED change event after a successful delete. However, deleteLineageBySource (used by the DELETE .../lineage/{entityType}/{entityId}/{lineageSource} endpoint) deletes one or more lineage edges (deleteLineageBySourcePipeline / deleteLineageBySource) but does not emit any change event.

This is an inconsistency: consumers/alerts subscribed to lineage-deleted events will be notified for single-edge deletions but silently miss source-based bulk deletions. If lineage change events are meant to drive notifications/audit, the source-based path should emit ENTITY_LINEAGE_DELETED for each removed relation (it already loads relations for deleteLineageFromSearch).

🤖 Prompt for agents
Code Review: Decomposes CollectionDAO and EntityRepository into cohesive domain-specific collaborators while fixing several persistence bugs. Access control on lineage audit fields requires hardening, as the new implementation incorrectly trusts client-provided audit timestamps and user IDs.

1. ⚠️ Security: Lineage audit fields createdBy/updatedBy now trust client payload
   Files: openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LineageRepository.java:1752-1766, openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LineageRepository.java:169

   The refactor of `addLineage` replaced the previously authoritative audit-field assignment with `applyTemporalFields`, which now derives `createdBy`, `updatedBy`, `createdAt` and `updatedAt` from the incoming request body when no prior record exists.
   
   Previously the code unconditionally did:
   ```
   lineageDetails.setCreatedBy(updatedBy);
   lineageDetails.setUpdatedBy(updatedBy);
   lineageDetails.setCreatedAt(currentTime);
   lineageDetails.setUpdatedAt(System.currentTimeMillis());
   ```
   so these fields always reflected the authenticated principal (`updatedBy`) and server time.
   
   Now:
   - `resolveCreatedBy` returns `incoming.getCreatedBy()` when there is no prior record.
   - `applyTemporalFields` sets `updatedBy` to `incoming.getUpdatedBy() != null ? incoming.getUpdatedBy() : fallbackUser` — i.e. a client-supplied `updatedBy` in the `LineageDetails` request body overrides the authenticated user.
   - `resolveUpdatedAt`/`resolveCreatedAt` likewise honor client-supplied timestamps.
   
   Because `lineageDetails` comes directly from the `AddLineage` request body (see `addLineage`), a caller can spoof the provenance/audit fields (who created/updated the lineage edge and when), and these spoofed values are persisted and propagated into the emitted `ENTITY_LINEAGE_ADDED/UPDATED` change events. This is an audit-integrity regression.
   
   Suggested fix: keep the prior-record preservation behavior for consolidation, but always force `updatedBy` to the authenticated `fallbackUser`, and ignore client-supplied `createdBy` on initial creation (use `fallbackUser`). Timestamps should be server-generated.

   Fix (Force updatedBy/updatedAt to the authenticated user and server time; only preserve the original createdBy from the prior record.):
   private static void applyTemporalFields(
       LineageDetails incoming, LineageDetails prior, String fallbackUser, long now) {
     incoming.setCreatedAt(resolveCreatedAt(incoming, prior, now));
     // Provenance must reflect the authenticated principal, never client-supplied values.
     incoming.setCreatedBy(prior != null && prior.getCreatedBy() != null ? prior.getCreatedBy() : fallbackUser);
     incoming.setUpdatedAt(now);
     incoming.setUpdatedBy(fallbackUser);
   }

Options

Display: compact → Showing less information.

Comment with these commands to change:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Jun 7, 2026

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend safe to test Add this label to run secure Github workflows on PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decompose CollectionDAO & EntityRepository god-classes and fix latent DAO-layer perf/correctness bugs

1 participant