Skip to content

fix(knowledge): prevent cyclic page reparenting in Context Center#29552

Open
sonika-shah wants to merge 2 commits into
open-metadata:mainfrom
sonika-shah:fix/knowledge-page-cycle-guard
Open

fix(knowledge): prevent cyclic page reparenting in Context Center#29552
sonika-shah wants to merge 2 commits into
open-metadata:mainfrom
sonika-shah:fix/knowledge-page-cycle-guard

Conversation

@sonika-shah

@sonika-shah sonika-shah commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What

Prevent cyclic reparenting of Context Center pages (KnowledgePage). Moving a page under itself or one of its own descendants is now rejected with a 400 instead of silently corrupting the subtree.

Symptom

Users hit a banner in the Context Center: page instance for <fqn> not found. That string is the raw backend 404 (CatalogExceptionMessage.entityNotFound("page", fqn)) surfaced by the UI. It appears when the UI requests a page by its computed parentFQN.childName but the stored page has a different FQN — i.e. a stored-vs-computed FQN mismatch.

Root cause

Context Center "Articles" are KnowledgePage entities (pageType=Article) served at /v1/contextCenter/pages.

Normal reparenting already works correctly at every level — nesting, cross-parent subtree cascade, leaf→top, same-level, parent→child (KnowledgePageUpdater.updateParent recomputes the FQN and cascades to descendants). That part is not the bug.

The bug: there was no guard against moving a page under its own descendant. The move was accepted (200) and the FQN rewrite cascaded into an ever-growing, self-referential chain. Example after moving Other (an ancestor of A) under A:

Other  fqn = ...A.Other            <- now under its own descendant
Root   fqn = ...Other.Root
A      fqn = ...Other.Root.A

Every page in the subtree is corrupted, and the resulting FQNs no longer match what the UI computes → the "not found" banner.

GlossaryTermRepository already guards the equivalent move (invalidGlossaryTermMove + descendant check); KnowledgePageRepository did not.

Fix

  • KnowledgePageRepository.prepare() now calls validateParentHierarchy(), which rejects the reparent when the new parent is the page itself or a descendant of it (FullyQualifiedName.isParent(parentFqn, pageFqn)), throwing IllegalArgumentException400.
  • Decision logic isolated in a pure, unit-testable helper isCyclicParentMove(pageId, pageFqn, parentId, parentFqn).
  • New message CatalogExceptionMessage.invalidPageMove(page, newParent).

Legitimate moves are unaffected — the guard only triggers when the target parent is the moved page or one of its descendants.

Testing

UnitKnowledgePageRepositoryTest (7 cases): self, direct child, deep descendant → rejected; unrelated parent, top-level, shared-name-prefix sibling, new-page-without-FQN → allowed. Green.

End-to-end (local build, 7-scenario move matrix):

Scenario Before After
nest at levels 1/2/3 pass pass
cross-parent subtree cascade pass pass
leaf → top pass pass
subtree → top, cascade pass pass
same-level reparent pass pass
parent → child w/ cascade pass pass
cycle (move under own descendant) 200, subtree corrupted 400, page unchanged

Global FQN-consistency sweep over all created pages: inconsistent: 1 (before) → inconsistent: 0 (after).

Note

This only prevents new corruption. Pages already corrupted by a previous cyclic move keep their bad FQNs until re-moved.

Greptile Summary

This PR adds a cycle-prevention guard to KnowledgePageRepository.prepare() that rejects any reparent move where the target parent is the page itself or one of its own descendants, throwing IllegalArgumentException (HTTP 400) instead of silently cascading a corrupt FQN chain. The fix mirrors the equivalent guard already present in GlossaryTermRepository and ContainerRepository.

  • KnowledgePageRepository.prepare() now calls validateParentHierarchy(), which fetches the parent from the DB and delegates to the pure isCyclicParentMove() helper; the helper checks identity by UUID and uses FullyQualifiedName.isParent to detect descendant relationships via dot-boundary-aware prefix matching.
  • CatalogExceptionMessage.invalidPageMove adds a consistent error message alongside the existing invalidGlossaryTermMove / invalidContainerMove messages.
  • Coverage includes 7 unit tests (KnowledgePageRepositoryTest) exercising all cycle/non-cycle combinations and a full integration test suite (KnowledgePageMoveIT) covering create nesting, cascaded renames, leaf promotion, and cycle rejection end-to-end.

Confidence Score: 5/5

Safe to merge — the guard is purely additive, correctly rejects only the cyclic case, and leaves all legitimate reparent paths unaffected.

The cycle-detection logic is correct: UUID identity check catches self-moves, and FullyQualifiedName.isParent(parentFqn, pageFqn) (dot-boundary-aware prefix match) catches descendant moves, using the same argument order as the equivalent GlossaryTermRepository guard. Null guards on both FQN and ID prevent false positives on creates. Unit and integration tests cover all relevant branches including the shared-name-prefix sibling edge case.

No files require special attention.

Important Files Changed

Filename Overview
openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/KnowledgePageRepository.java Adds validateParentHierarchy called from prepare() and the static isCyclicParentMove helper; logic is correct, parameter order in FullyQualifiedName.isParent matches the existing GlossaryTermRepository pattern, and null guards prevent false positives on creates.
openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java Adds invalidPageMove error message, consistent with the existing invalidGlossaryTermMove and invalidContainerMove helpers; no issues.
openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/KnowledgePageRepositoryTest.java Seven unit tests covering all cycle/non-cycle branches of isCyclicParentMove, including the dot-boundary sibling prefix edge case and the null-FQN-on-create case; comprehensive and correct.
openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/KnowledgePageMoveIT.java End-to-end integration tests covering nesting, multi-level cascade, leaf promotion, same-level reparent, move-under-descendant rejection (400), self-move rejection (400), and deep cascade; FQN assertions correctly use the nested() helper and re-fetch from DB via fqnOf().

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant PageResource
    participant KnowledgePageRepository
    participant DB

    Client->>PageResource: "PATCH /v1/contextCenter/pages/{id}"
    PageResource->>KnowledgePageRepository: patch(original, updated)
    KnowledgePageRepository->>KnowledgePageRepository: "prepare(page, update=true)"
    KnowledgePageRepository->>KnowledgePageRepository: validateParentHierarchy(page)
    alt parentRef present
        KnowledgePageRepository->>DB: getEntityReferenceById(parentRef.getId())
        DB-->>KnowledgePageRepository: parent (with FQN)
        KnowledgePageRepository->>KnowledgePageRepository: isCyclicParentMove(pageId, pageFqn, parentId, parentFqn)
        alt "pageId == parentId OR isParent(parentFqn, pageFqn)"
            KnowledgePageRepository-->>PageResource: IllegalArgumentException (400)
            PageResource-->>Client: 400 Bad Request
        else valid move
            KnowledgePageRepository->>KnowledgePageRepository: KnowledgePageUpdater.updateParent()
            KnowledgePageRepository->>DB: update FQN + cascade to descendants
            DB-->>KnowledgePageRepository: ok
            KnowledgePageRepository-->>PageResource: updated Page
            PageResource-->>Client: 200 OK
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant PageResource
    participant KnowledgePageRepository
    participant DB

    Client->>PageResource: "PATCH /v1/contextCenter/pages/{id}"
    PageResource->>KnowledgePageRepository: patch(original, updated)
    KnowledgePageRepository->>KnowledgePageRepository: "prepare(page, update=true)"
    KnowledgePageRepository->>KnowledgePageRepository: validateParentHierarchy(page)
    alt parentRef present
        KnowledgePageRepository->>DB: getEntityReferenceById(parentRef.getId())
        DB-->>KnowledgePageRepository: parent (with FQN)
        KnowledgePageRepository->>KnowledgePageRepository: isCyclicParentMove(pageId, pageFqn, parentId, parentFqn)
        alt "pageId == parentId OR isParent(parentFqn, pageFqn)"
            KnowledgePageRepository-->>PageResource: IllegalArgumentException (400)
            PageResource-->>Client: 400 Bad Request
        else valid move
            KnowledgePageRepository->>KnowledgePageRepository: KnowledgePageUpdater.updateParent()
            KnowledgePageRepository->>DB: update FQN + cascade to descendants
            DB-->>KnowledgePageRepository: ok
            KnowledgePageRepository-->>PageResource: updated Page
            PageResource-->>Client: 200 OK
        end
    end
Loading

Reviews (2): Last reviewed commit: "test(knowledge): add move-scenario integ..." | Re-trigger Greptile

Moving a KnowledgePage under itself or one of its own descendants was
accepted and rewrote the subtree's fully-qualified names into an
ever-growing self-referential chain (a.b.a.b.a...), corrupting every
page in the subtree. The corrupted FQNs later surface in the UI as the
banner "page instance for <fqn> not found".

Add a cycle guard in KnowledgePageRepository.prepare() that rejects a
reparent when the target parent is the page itself or one of its
descendants, throwing a 400 (mirrors GlossaryTermRepository). Valid
moves at every level - nesting, cross-parent subtree cascade,
leaf->top, same-level reparent, parent->child - are unaffected.

Adds CatalogExceptionMessage.invalidPageMove and unit tests for the
cycle-detection logic.
Copilot AI review requested due to automatic review settings June 28, 2026 18:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

❌ PR checklist incomplete

This PR cannot be merged until the following are addressed on its linked issue:

  • No GitHub issue is linked. Link an issue in the Development section of the PR (or add Fixes #12345 to the description). For a same-org cross-repo issue, add Fixes open-metadata/<repo>#123 to the description.

The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically.

Maintainers can bypass this check by adding the skip-pr-checks label.

@github-actions github-actions Bot added backend safe to test Add this label to run secure Github workflows on PRs labels Jun 28, 2026
Comment on lines +438 to +440
private void validateParentHierarchy(Page page) {
EntityReference parentRef = page.getParent();
if (parentRef != null && parentRef.getId() != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Unnecessary DB round-trip on every prepare() call — validateParentHierarchy fetches the parent from the database even when update=false (creates) or when the parent hasn't changed. On create, page.getFullyQualifiedName() is always null so isCyclicParentMove can never return true, yet the getEntityReferenceById lookup still fires. Compare GlossaryTermRepository, which gates the equivalent check on existingTerm != null (i.e., only on updates). Skipping the check when the page has no FQN yet avoids this overhead.

Suggested change
private void validateParentHierarchy(Page page) {
EntityReference parentRef = page.getParent();
if (parentRef != null && parentRef.getId() != null) {
private void validateParentHierarchy(Page page) {
// A new page has no FQN yet and cannot be a descendant of anything; skip the check.
if (page.getFullyQualifiedName() == null) {
return;
}
EntityReference parentRef = page.getParent();
if (parentRef != null && parentRef.getId() != null) {

@github-actions

github-actions Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

🔴 Playwright Results — 2 failure(s), 36 flaky

✅ 4437 passed · ❌ 2 failed · 🟡 36 flaky · ⏭️ 39 skipped

Shard Passed Failed Flaky Skipped
🔴 Shard 1 319 1 4 5
🔴 Shard 2 820 1 8 9
🟡 Shard 3 823 0 6 7
🟡 Shard 4 826 0 5 10
🟡 Shard 5 870 0 4 0
🟡 Shard 6 779 0 9 8

Genuine Failures (failed on all attempts)

Features/DescriptionSuggestion.spec.ts › should decline a requested api endpoint request schema field description (shard 1)
�[31mTest timeout of 180000ms exceeded.�[39m
Features/BulkImport.spec.ts › Table (shard 2)
Error: �[2mexpect(�[22m�[31mlocator�[39m�[2m).�[22mtoHaveText�[2m(�[22m�[32mexpected�[39m�[2m)�[22m failed

Locator:  getByTestId('processed-row')
Expected: �[32m"11"�[39m
Received: �[31m"2"�[39m
Timeout:  90000ms

Call log:
�[2m  - Expect "toHaveText" with timeout 90000ms�[22m
�[2m  - waiting for getByTestId('processed-row')�[22m
�[2m    10 × locator resolved to <span class="font-semibold" data-testid="processed-row">4</span>�[22m
�[2m       - unexpected value "4"�[22m
�[2m    83 × locator resolved to <span class="font-semibold" data-testid="processed-row">2</span>�[22m
�[2m       - unexpected value "2"�[22m

🟡 36 flaky test(s) (passed on retry)
  • Features/DataAssetRulesEnabled.spec.ts › Verify the Pipeline Entity Action items after rules is Enabled (shard 1, 1 retry)
  • Features/DataAssetRulesEnabled.spec.ts › should enforce single domain selection for glossary term when entity rules are enabled (shard 1, 1 retry)
  • Features/TagsSuggestion.spec.ts › should decline suggested tags for a container column (shard 1, 1 retry)
  • Flow/SearchRBAC.spec.ts › the browse tree only shows the asset-type categories a user can access (shard 1, 1 retry)
  • Features/BulkEditEntity.spec.ts › Glossary (shard 2, 1 retry)
  • Features/BulkEditOperationBadges.spec.ts › Glossary bulk edit search filters rows and clear restores them (shard 2, 1 retry)
  • Features/BulkImport.spec.ts › Database (shard 2, 1 retry)
  • Features/BulkImportWithDotInName.spec.ts › Full import cycle with dot in service name (shard 2, 2 retries)
  • Features/Container.spec.ts › Container page should show Schema and Children count (shard 2, 1 retry)
  • Features/Container.spec.ts › Container page children pagination (shard 2, 1 retry)
  • Features/Container.spec.ts › Copy column link button should copy the column URL to clipboard (shard 2, 1 retry)
  • Features/DomainTierCertificationVoting.spec.ts › DataProduct - Tier assign, update, and remove (shard 2, 1 retry)
  • Features/LandingPageWidgets/FollowingWidget.spec.ts › Check followed entity present in following widget (shard 3, 1 retry)
  • Features/Permissions/EntityPermissions.spec.ts › Dashboard allow entity-specific permission operations (shard 3, 1 retry)
  • Features/RecentlyViewed.spec.ts › Check Pipeline in recently viewed (shard 3, 1 retry)
  • Features/SearchExport.spec.ts › Export queues a background job and downloads from the jobs tray (shard 3, 1 retry)
  • Features/SearchIndexNestedColumns.spec.ts › 25-level oversized nested column indexes and is searchable by its deep column name (shard 3, 1 retry)
  • Features/Table.spec.ts › Table pagination with sorting should works (shard 3, 1 retry)
  • Flow/LineageSettings.spec.ts › Verify global lineage config (shard 4, 1 retry)
  • Flow/NestedChildrenUpdates.spec.ts › should add and remove tags to nested column immediately without refresh (shard 4, 1 retry)
  • Flow/NestedChildrenUpdates.spec.ts › should add and remove tags to nested column immediately without refresh (shard 4, 1 retry)
  • Pages/DataContracts.spec.ts › Contract Status badge should be visible on condition if Contract Tab is present/hidden by Persona (shard 4, 1 retry)
  • Pages/DataContractsSemanticRules.spec.ts › Validate DataProduct Rule Any_In (shard 4, 1 retry)
  • Pages/Domains.spec.ts › Verify domain and subdomain asset count accuracy (shard 5, 1 retry)
  • Pages/EntityDataConsumer.spec.ts › No edit owner permission (shard 5, 1 retry)
  • Pages/EntityDataConsumer.spec.ts › User as Owner Add, Update and Remove (shard 5, 1 retry)
  • Pages/EntityDataSteward.spec.ts › Update description (shard 5, 1 retry)
  • Pages/GlossaryImportExport.spec.ts › Import partial success - some terms pass, some fail (shard 6, 1 retry)
  • Pages/GlossaryImportExport.spec.ts › Glossary CSV import preserves typed relations (shard 6, 1 retry)
  • Pages/Lineage/DataAssetLineage.spec.ts › Column lineage for container -> dashboardDataModel (shard 6, 1 retry)
  • ... and 6 more

📦 Download artifacts

How to debug locally
# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip    # view trace

…er pages

Adds KnowledgePageMoveIT covering the full reparent matrix end-to-end:
nesting at each level, move of a top-level page under a parent, subtree FQN
cascade (single and multi-level), leaf -> top, same-level reparent, and the two
cycle cases (move under self / under own descendant) which must return 400 and
leave the FQNs untouched.

Complements the KnowledgePageRepositoryTest unit coverage for the cycle guard.
@gitar-bot

gitar-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 1 resolved / 1 findings

Adds a cycle-detection guard to KnowledgePageRepository that rejects invalid reparenting moves, preventing subtree corruption and associated 404 errors. Includes comprehensive unit and integration tests to verify the new 400 error path.

✅ 1 resolved
Quality: Cyclic-move guard lacks integration test for the 400 path

📄 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/KnowledgePageRepositoryTest.java:21-35 📄 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/KnowledgePageRepository.java:438-452
The new tests (KnowledgePageRepositoryTest) only exercise the pure helper isCyclicParentMove. The actual end-to-end behavior that this PR fixes — validateParentHierarchy() resolving the parent via Entity.getEntityReferenceById(...) and throwing IllegalArgumentException (mapped to a 400) when reparenting a page under its own descendant — is verified only by manual local E2E runs described in the PR, not by an automated test. Per the project testing philosophy (integration tests in openmetadata-integration-tests/ preferred, test outcomes/API responses rather than internal methods), consider adding an integration test that performs a real reparent-under-descendant PATCH/PUT against the /v1/contextCenter/pages endpoint and asserts a 400 plus an unchanged subtree. This protects against regressions in the wiring (e.g. prepare() ordering vs. FQN recomputation, parent resolution) that the unit test cannot catch.

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

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.

2 participants