Skip to content

fix(taxonomy): 404 node records on a tenant mismatch (ENG-1887) - #118

Merged
BhagyaAmarasinghe merged 2 commits into
mainfrom
anshuman/eng-1887-node-records-404-on-tenant-mismatch
Jul 31, 2026
Merged

fix(taxonomy): 404 node records on a tenant mismatch (ENG-1887)#118
BhagyaAmarasinghe merged 2 commits into
mainfrom
anshuman/eng-1887-node-records-404-on-tenant-mismatch

Conversation

@pandeymangg

@pandeymangg pandeymangg commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Linear: https://linear.app/formbricks/issue/ENG-1887/triage-drilldown-nodesidrecords-returns-200-empty-not-404-on-tenant

GET /v1/taxonomy/nodes/{node_id}/records returned 200 with an empty data when the node belonged to another tenant, while every sibling node-scoped operation returns 404 for the same mismatch:

Endpoint On tenant mismatch
GET /v1/taxonomy/runs/{run_id} 404
GET /v1/taxonomy/runs/{run_id}/tree 404
GET /v1/taxonomy/runs/{run_id}/record-counts 404
PATCH /v1/taxonomy/nodes/{node_id} (rename) 404
DELETE /v1/taxonomy/nodes/{node_id} (soft remove) 404
GET /v1/taxonomy/nodes/{node_id}/records 200 + empty ← the odd one out

Nothing leaked. The tenant is a predicate on the records query (WHERE tr.tenant_id = $2), so a foreign node simply matches no rows. This is a contract problem, not an isolation one — surfaced by the ENG-1214 tenant-isolation verification, which confirmed zero cross-tenant records.

The problem is that the answer was ambiguous. "Empty" meant three different things at once and a caller could not tell them apart:

  1. the node is yours and genuinely holds no records
  2. the node belongs to someone else
  3. the node does not exist

The change

ListNodeRecords now resolves ownership before running the records query — the same shape CountNodeRecords and GetTree already use:

if _, err := r.GetNodeForTenant(ctx, nodeID, tenantID); err != nil {
    return nil, 0, err
}

getNodeForTenant is the read-only counterpart of the existing getNodeForUpdate: same ownership predicate, no row lock. The predicate itself moves into a shared taxonomyNodeForTenantWhere const so the locking write path and the read path cannot drift on what "yours" means.

Both reads run inside one REPEATABLE READ, read-only transaction. That matters: on separate statements each read gets its own READ COMMITTED snapshot, so a RemoveNode committing between them would pass the guard and then match no rows in the CTE anchor (WHERE id = $1 AND removed_at IS NULL) — handing back the very 200-empty this contract exists to remove. Worse, it would not even be a state that ever existed: soft-remove leaves cluster memberships intact, so the node held records at guard time and holds them still.

Postgres takes the REPEATABLE READ snapshot at the first statement, so the node is either visible to both reads or to neither — a removal landing after the guard still returns the records, one landing before it returns 404. Read-only, so there is no write conflict to serialize against (could not serialize access cannot occur here) and the deferred rollbackQuietly is the only exit.

Ownership stays non-enumerable: an unknown node id, a foreign node and a removed node are all the same 404.

openapi.yaml documents the 404 on the endpoint and drops the now-inaccurate description. That regenerates the listRecords SDK docstring, which promised only "Tenant-scoped" while rename / softRemove already promised "404 if the node does not belong to the tenant".

Behaviour change to be aware of

A soft-removed node of your own tenant now 404s where it previously returned 200-empty. That is deliberate:

  • it matches rename and softRemove, which already 404 on a removed node;
  • the tree has already dropped the node, so a caller asking for its records is working from stale state.

If we would rather keep removed nodes readable, it is one line — drop removed_at IS NULL from the shared const.

API behaviour: before / after

Request, with node_id owned by tenant org-A:

GET /v1/taxonomy/nodes/019f177f-9abe-78cd-8008-f40b58e3147d/records?tenant_id=org-B

Before:

HTTP/1.1 200 OK
Content-Type: application/json

{ "data": [], "limit": 50 }

After:

HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
  "type": "https://hub.formbricks.com/problems/not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "taxonomy node not found",
  "code": "taxonomy_node"
}

A node that is yours and genuinely holds no records still returns 200 { "data": [], "limit": … } — and now that answer actually means something.

Consumers

ListNodeRecords has exactly one caller — the public endpoint itself. No worker or internal service depends on it, so the blast radius is that one route.

On the Formbricks Web side, ENG-1886 (formbricks#8707) already maps a Hub 404 on this call to a 404 of its own. That mapping is currently unreachable because the Hub never sends one; once this merges it starts working with no further change on the web side.

How should this be tested?

Automated (all run locally against compose.yml Postgres on POSTGRES_PORT=5433, migrations at version 20):

  • make build → both binaries built ✅
  • make testsok github.com/formbricks/hub/tests
  • go test ./... → all 17 packages green ✅
  • make fmt + make lint0 issues
  • make lint-openapiNo results with a severity of 'error' found!
  • Pre-commit hook (fmt + lint + unit tests) passed on commit ✅

Tests updated — both places that pinned the old behaviour, plus new coverage:

Test Now asserts
tests/taxonomy_api_test.gonode records 404 for another tenant HTTP 404 (was 200 + empty), alongside the existing run / tree / rename / remove 404 cases
tests/taxonomy_persistence_test.goTestTaxonomyRepository_ListNodeRecords foreign tenant → ErrNotFound; unknown node id → the same ErrNotFound; soft-removed node → ErrNotFound; surviving root → 200-empty, which is now a meaningful answer
tests/taxonomy_persistence_test.goTestTaxonomyRepository_TenantIsolation new node records refuse another tenant subtest, so the isolation suite covers every tenant-scoped op as its docstring claims

Not covered by a test: the concurrent-removal window itself. Exercising it needs a removal to commit between the two statements inside ListNodeRecords, which would require a test seam in the method; any goroutine-and-sleep approximation would be flaky. The contract either side of the race is covered by the tests above, and the snapshot change is exercised by the whole suite still passing. Happy to add the seam and a deterministic test if reviewers would rather have it.

Manual reproduction against a local Hub:

  1. Seed a taxonomy for tenant org-A and note a node id from the tree.
  2. GET /v1/taxonomy/nodes/{node_id}/records?tenant_id=org-A200 with the assigned records.
  3. Same node id with ?tenant_id=org-B404 application/problem+json (was 200 + "data": []).
  4. A random UUID with ?tenant_id=org-A → the same 404, so ownership is not enumerable.
  5. DELETE the node, then re-run step 2 → 404.
  6. A node of your own that has no cluster memberships → still 200 with "data": [].

Test configuration: DATABASE_URL=postgres://postgres:postgres@localhost:5433/test_db?sslmode=disable, API key from .env.

Checklist

Required

  • Filled out the "How to test" section in this PR
  • Read Repository Guidelines
  • Self-reviewed my own code
  • Commented on my code in hard-to-understand bits
  • Ran make build
  • Ran make tests (integration tests in tests/)
  • Ran make fmt and make lint; no new warnings
  • Removed debug prints / temporary logging
  • Merged the latest changes from main onto my branch with git pull origin main
  • If database schema changed: added migration in migrations/ with goose annotations and ran make migrate-validate — n/a, no schema change

Appreciated

  • If API changed: added or updated OpenAPI spec and ran contract tests (make tests or API contract workflow)
  • If API behavior changed: added request/response examples or Swagger UI screenshots to this PR
  • Updated docs in docs/ if changes were necessary — n/a, the endpoint's contract lives in openapi.yaml, which is updated here
  • Ran make tests-coverage for meaningful logic changes

GET /v1/taxonomy/nodes/{node_id}/records returned 200 with an empty data
array when the node belonged to another tenant, while every sibling
node-scoped operation — runs.retrieve, record-counts, rename, softRemove —
returns 404 for the same mismatch.

Nothing leaked: the tenant is a predicate on the records query, so a foreign
node simply matches no rows. The problem is that the answer was ambiguous.
"Empty" meant three different things at once — the node is yours and holds
no records, the node is someone else's, or the node does not exist — and a
caller could not tell them apart.

ListNodeRecords now resolves ownership before running the records query, the
same shape CountNodeRecords and GetTree already use. The ownership predicate
moves into a shared taxonomyNodeForTenantWhere const so the locking write
path and the new read path cannot drift on what "yours" means.

A soft-removed node of your own tenant now 404s too, where it previously
returned 200-empty. That matches rename and softRemove, and the tree has
already dropped the node, so a caller asking for its records is working from
stale state.

Ownership stays non-enumerable: an unknown node id, a foreign node and a
removed node are all the same 404.

Also documents the 404 on the endpoint in openapi.yaml, which regenerates
the listRecords SDK docstring — it promised only "Tenant-scoped" while
rename/softRemove already promised the 404.
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

✱ Stainless preview builds

This PR will update the hub SDKs with the following commit message.

fix(taxonomy): 404 node records on a tenant mismatch (ENG-1887)
hub-openapi studio · code

Your SDK build had at least one "note" diagnostic.
generate ✅

hub-typescript studio · code

Your SDK build had at least one "note" diagnostic.
generate ✅build ✅lint ✅test ✅

npm install https://pkg.stainless.com/s/hub-typescript/bd87201a5af334044acad1a9427820a07148e00d/dist.tar.gz

This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-07-31 20:07:03 UTC

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Taxonomy node record lookup now validates that the node is visible and belongs to the tenant before querying records. A new GetNodeForTenant method and shared ownership predicate support this validation and the update path. The API documentation now defines 404 responses for cross-tenant, unknown, and soft-removed nodes. Tests cover these cases and preserve empty results for valid nodes without records.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the tenant-mismatch 404 behavior change and follows the Conventional Commits format.
Description check ✅ Passed The description is complete and covers the change, motivation, API behavior, testing, affected consumers, and checklist status.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/repository/taxonomy_repository.go`:
- Around line 1049-1055: The node ownership validation and records retrieval
must use one consistent database read so concurrent RemoveNode commits cannot
produce a false empty success. Update the method containing the GetNodeForTenant
guard and recursive records query to combine validation with retrieval in one
SQL statement or use a consistent locking transaction snapshot, preserving the
required 404 behavior when the node is removed or does not belong to the tenant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4250ff75-0073-4f74-9c2a-de354a57840f

📥 Commits

Reviewing files that changed from the base of the PR and between 4993dfb and 9bcdae6.

📒 Files selected for processing (4)
  • internal/repository/taxonomy_repository.go
  • openapi.yaml
  • tests/taxonomy_api_test.go
  • tests/taxonomy_persistence_test.go

Comment thread internal/repository/taxonomy_repository.go
The ownership guard and the recursive records query ran as two separate
statements, each on its own READ COMMITTED snapshot. A RemoveNode committing
between them passed the guard and then matched no rows in the CTE anchor
(WHERE id = $1 AND removed_at IS NULL), handing back 200 with an empty data
array — the exact ambiguity this endpoint's 404 contract exists to remove.

The result was not just ambiguous but unlinearizable: soft-remove leaves
cluster memberships intact, so the node did hold records at guard time and
holds them still. "Empty" described no point in time.

Both reads now share one REPEATABLE READ, read-only transaction. Postgres
takes the snapshot at the first statement, so the node is either visible to
both reads or to neither: a removal landing after the guard still returns the
records, one landing before it returns 404. Read-only, so there is no write
conflict to serialize against and the deferred rollback is the only exit.

GetNodeForTenant becomes the package-level getNodeForTenant taking a queryer,
so the caller owns the snapshot. It had a single caller, so nothing else
changes; it also no longer needs to be exported.
@pandeymangg

Copy link
Copy Markdown
Contributor Author

Verified — the finding is valid, and fixed in beeae71.

The race, concretely. The guard and the records query ran as two statements, each on its own READ COMMITTED snapshot:

  1. getNodeForTenant → node visible, tenant matches ✅
  2. a concurrent RemoveNode commits
  3. the records query → the CTE anchor WHERE id = $1 AND removed_at IS NULL no longer matches → zero rows → 200 {"data": []}

So a node removed mid-request came back as the ambiguous 200-empty that this PR exists to eliminate. It was also not merely ambiguous but unlinearizable: soft-remove leaves taxonomy_cluster_memberships intact, so the node held records at guard time and holds them still. "Empty" described no point in time.

The fix. Both reads now share one REPEATABLE READ, read-only transaction:

dbTx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
    return nil, 0, fmt.Errorf("begin taxonomy node records tx: %w", err)
}

defer rollbackQuietly(ctx, dbTx, "list taxonomy node records: rollback failed")

if _, err := getNodeForTenant(ctx, dbTx, nodeID, tenantID); err != nil {
    return nil, 0, err
}

rows, err := dbTx.Query(ctx, `WITH RECURSIVE visible_nodes AS (...)`)

Postgres takes the snapshot at the first statement, not at BEGIN, so the node is either visible to both reads or to neither:

  • removal commits after the guard → the records query still sees the node → returns its records
  • removal commits before the guard → 404

Either way the 200-empty-for-a-removed-node outcome is gone.

I went with the snapshot rather than folding it into a single statement: making one query distinguish "root missing" from "root present but empty" needs a LEFT JOIN off the root CTE, which null-pads the record columns and breaks scanFeedbackRecord. The transaction keeps the SQL and the scanning untouched, and BeginTx is already the idiom here (tenant_data_repository.go, embeddings_repository.go).

Two notes:

  • Read-only, so there is no write conflict to serialize against — could not serialize access cannot occur on this path.
  • GetNodeForTenant became the package-level getNodeForTenant(ctx, q queryer, ...) so the caller owns the snapshot. It had exactly one caller, so nothing else moves, and it no longer needs to be exported.

On testing the window itself: I have not added a test for it. Reproducing it needs a removal to commit between the two statements inside ListNodeRecords, which means a test seam in the method; a goroutine-and-sleep approximation would just be flaky. The contract either side of the race is covered, and the change is exercised by the full suite. Say the word if you would rather have the seam and a deterministic test.

go build ./..., make lint (0 issues) and go test ./... (17 packages) all green.

@BhagyaAmarasinghe
BhagyaAmarasinghe added this pull request to the merge queue Jul 31, 2026
Merged via the queue into main with commit 7cc0368 Jul 31, 2026
11 checks passed
@BhagyaAmarasinghe
BhagyaAmarasinghe deleted the anshuman/eng-1887-node-records-404-on-tenant-mismatch branch July 31, 2026 20:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants