Skip to content

fix(store): return every org service user and honour the requested sort - #1929

Merged
rohilsurana merged 20 commits into
mainfrom
fix/search-org-service-users-missing-rows
Sep 21, 2026
Merged

rohilsurana merged 20 commits into
mainfrom
fix/search-org-service-users-missing-rows

Conversation

@rohilsurana

@rohilsurana rohilsurana commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

SearchOrganizationServiceUsers returned fewer service users than ListOrganizationServiceUsers for the same org. Digging in turned up five separate bugs. All three are confirmed on a local Frontier, before and after.

Bug 1: service users without a project policy were dropped

ListOrganizationServiceUsers reads the serviceusers table directly, so it returns every service user in the org. The search query built its rows with two inner joins:

FROM serviceusers
INNER JOIN policies ON serviceusers.id = policies.principal_id
                   AND policies.principal_type = 'app/serviceuser'
                   AND policies.resource_type  = 'app/project'
INNER JOIN projects ON policies.resource_id = projects.id

A service user only survived those joins if it had at least one policy on a project. Service users with only an org level policy, or with no policy at all, were dropped before any filter or limit ran. The projects join also never checked projects.deleted_at, so a deleted project still showed up in the response.

Both joins are now LEFT JOIN, the projects join requires projects.deleted_at IS NULL, and the aggregate is wrapped in COALESCE(... FILTER (WHERE projects.id IS NOT NULL), '[]'). The filter drops the null row a left join produces, and the coalesce returns [] rather than SQL NULL, which the row struct scans as a plain string.

Bug 2: the requested sort was ignored

The base query ended with a fixed ORDER BY serviceusers.title ASC, and the requested sort was appended after it. So the fixed term always won and the requested sort only broke ties between rows with the same title. Titles are mostly unique, so it did nothing.

The old expected SQL showed it plainly. Asking for title descending produced:

ORDER BY "serviceusers"."title" ASC, "serviceusers"."title" DESC

which is just title ascending.

The base query no longer sets an order. The requested sort leads, title ASC is used only when no sort is asked for, and a final id ASC term makes the order total so paging by offset cannot repeat or skip a row.

Bug 3: filtering on created_at always failed

AggregatedServiceUser tagged its fields like rql:"filter,sort,column=created_at". Salt resolves an rql field by the name tag (or the lowercased Go field name) and reads its type from the type tag. It ignores column. So created_at, org_id and updated_at did not resolve at all, and every field that did resolve fell back to type string.

The result was that any filter on created_at failed in the handler before reaching the database:

invalid_argument: failed to read rql query: 'created_at' is not a valid field

Tags are now rql:"name=created_at,type=datetime" and so on, matching core/prospect/prospect.go.

Refactor: use the shared rql helpers

The repository had its own addFilter, addSearch and addSort. These are gone, replaced by utils.AddRQLFiltersInQuery, AddRQLSearchInQuery, AddRQLSortInQuery and AddRQLPaginationInQuery from pkg/utils/rql.go, as used by prospect_repository.go, userpat_repository.go and audit_record_repository.go.

One wrinkle. Those helpers emit unqualified column names, and the three joined tables here all have an id, a title and a created_at. An unqualified name in ORDER BY resolves to the select alias and is fine, but in WHERE Postgres rejects it:

ERROR:  column reference "title" is ambiguous

So the joined and grouped query is now wrapped in a subquery, and rql is applied to the wrapper where only the aliased output columns are visible. Explicit allowlists say which columns may be filtered, searched and sorted, and unsupported ones are rejected as ErrBadInput, which the handler maps to invalid_argument instead of internal.

Bug 4: a project could be listed twice

If a service user holds two roles on the same project, it has two policy rows, so the join produces two rows and the project appeared twice in the response. The admin UI renders the titles joined by a comma, so it showed "Live Project, Live Project". Confirmed on a local database.

The aggregate is now JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(...)). It has to be jsonb rather than json because the json type has no equality operator, so DISTINCT cannot be applied to it.

Bug 5: the UI offers a Projects filter the API rejected

web/sdk/admin/views/organizations/details/apis/columns.tsx marks the Projects column enableColumnFilter: true. That filter never worked. The old repository silently ignored any filter it did not recognise, so the results came back unfiltered. Filtering for %Live% and for %zzzz% both returned the same row.

The base query now also aggregates the project titles into a projects text column, using the same comma separated form the UI renders. That column is allowlisted for filters and for search, so the filter does what it says. Filtering for %zzzz% now correctly returns nothing.

Checked against the admin UI

web/ in this repo holds the admin UI, so the request shapes are not guesswork.

  • The like change is a non-issue. I flagged earlier that the shared helper stops auto-wrapping like values in %. Apsara's DataTable never sends like. It maps contains, starts_with and ends_with to operator ilike and adds the wildcards itself in filter-operations.cjs. ilike took the same default code path before and after, so nothing changes. Verified: the UI's exact request, ilike with %policy%, returns the right rows.
  • The default limit change is a non-issue. transformDataTableQueryToRQLRequest sends limit: query.limit || defaultLimit with defaultLimit = 50, so the UI always sends a limit.
  • Pagination got more correct. The table uses useInfiniteQuery, and getConnectNextPageParam computes hasMorePages as items.length === pagination.limit and the next offset as pagination.offset + pagination.limit, both read from the response. The response now reports the limit actually applied rather than echoing the request, and the new id ASC tie breaker keeps pages from repeating or skipping rows.
  • The date filter was completely broken. The Projects view sets filterType: "date" on Created on and maps createdAt to created_at. Every such request failed with 'created_at' is not a valid field because of bug 3. It works now.
  • The default sort is the one that was ignored. That view's DEFAULT_SORT is { name: 'createdAt', order: 'desc' }, which is exactly the request in the original report.

Verified end to end on a local Frontier

Postgres and SpiceDB in docker, server built from this branch, seeded with four service users in one org: one with a project policy, one with only an org policy, one with no policy, and one whose policy points at a deleted project. Same request, two binaries.

Before, built from main:

2 returned:
   a-with-project     | projects: ['proj-live']
   d-deleted-project  | projects: ['proj-gone']     <- deleted project leaking

After, built from this branch, sorted created_at desc:

4 returned:
   d-deleted-project  | projects: []
   b-org-policy-only  | projects: []
   c-no-policy        | projects: []
   a-with-project     | projects: ['proj-live']

Also checked against the running server, with the UI's own request shapes:

case before after
sort title desc came back ascending d, c, b, a
sort created_at desc ignored d, b, c, a, newest first
filter created_at range, as the date picker sends it invalid_argument correct subset
filter title ilike %policy%, as the UI sends it returned nothing b, c
filter projects ilike %Live% filter ignored, wrong rows a-with-project
filter projects ilike %zzzz% returned a row anyway empty, correctly
two roles on one project project listed twice listed once
limit 2, offset 0 then 2 n/a a, b then c, d, no overlap
sort on updated_at internal invalid_argument
no limit given unbounded 50

Two regressions this PR introduced, now fixed

An independent review caught two cases where this branch turned a working or harmless request into a 500. Both reproduced against main and this branch on a local server.

group_by returned a 500. AddRQLSortInQuery prepends ORDER BY <group_by column> for every group_by entry, and the wrapper select has no such column. main returned 200 and ignored group_by. The repository now rejects group_by as ErrBadInput, which is what user_orgs_repository.go does, since this response has no group block to fill.

Bad filter values returned a 500. The handler never called rql.ValidateQuery, unlike ten other handlers including its closest sibling SearchOrganizationServiceUserCredentials. On main a bad created_at value happened to fail earlier with a 400, only because bug 3 meant the field never resolved. Once it resolved, the raw value reached Postgres. The handler now validates, and the ErrBadInput branch returns the real reason instead of a generic message.

request main before the review now
group_by: ["foo"] 200, ignored 500 400, 'foo' is not a valid sort key
group_by: ["title"] 200, ignored 200, sort silently overridden 400, group_by is not supported
created_at gt "yesterday-ish" 400 500 400, not a valid ISO datetime string
created_at with operator in 400 500 400, same

Tests

There are two layers.

TestOrgServiceUserRepository_prepareDataQuery pins the generated SQL. The joined part lives in one constant so each case shows only the WHERE, ORDER BY and pagination it exercises. That catches the sort and tag bugs, but on its own it would pass unchanged with the old inner joins, so it does not prove much about the main bug.

org_serviceuser_repository_pg_test.go is new and runs the real query against a real Postgres through the existing dockertest harness. It seeds the five shapes that actually broke: a service user with a live project policy, one with only an org policy, one with no policy, one whose project is soft deleted, and one holding two roles on a single project. It asserts the row set, the per-user project lists, both sorts, the created_at filter, the projects filter, search across both columns, disjoint pages, and the rejected inputs.

I checked that it fails on the bug rather than merely passing on the fix. Putting the two INNER JOINs back fails 5 of its 8 tests.

golangci-lint run reports 0 issues. The dockertest suites in this package need a reachable docker socket. With DOCKER_HOST=unix:///.../docker.sock the whole of internal/store/postgres, internal/api/v1beta1connect and core/... passes with no failures.

One CI change

The unit job failed with panic: test timed out after 2m30s, and the log names the package:

FAIL	github.com/raystack/frontier/internal/store/postgres	150.221s

That is make test running -count 2 -timeout 150s. The timeout is per package, and this package now has 25 dockertest suites, each starting its own postgres container, each run twice.

The budget was already almost gone before this PR. Measured locally with the CI flags, the package takes 117.2s without the new suite and 120.3s with it. The new suite costs 3.1s, about 3 percent. CI stopped at 150.2s, so it was sitting at roughly 147s already. This is consistent with the history on this branch, where the same timeout panic appeared at 61bb71ca and 821c0a6d, both before the new suite existed, while other pushes passed. It was flaky at the limit.

So -timeout goes from 150s to 600s. That gives the package real headroom rather than leaving it one suite away from failing again. The new suite itself is not flaky: -race -count 3 passes.

The better fix is for these suites to share one postgres container instead of starting 25. That is a change to shared test infrastructure and does not belong in this PR.

Follow-ups, not in this PR

  • No index leads with policies.principal_id, so every search sequentially scans policies. Pre-existing, and the real scaling limit here. A migration adding (principal_id, principal_type, resource_type) would fix it.
  • org_projects_repository.go has the same inner-join shape: a project with no human members disappears from SearchOrganizationProjects, and there is no deleted_at guard on projects or users.
  • SearchOrganizationServiceUsers returns 200 with an empty list for an org that does not exist, where ListOrganizationServiceUsers returns 404.
  • The plain list returns state; the search does not. That is the last shape difference between the two endpoints.
  • The 25 dockertest suites in internal/store/postgres each start their own container. Sharing one would cut minutes off every CI run.

@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: ad8e79d2-d7d9-435f-bc52-2aa225e7346a

📥 Commits

Reviewing files that changed from the base of the PR and between a3d8650 and 0e47579.

📒 Files selected for processing (5)
  • internal/api/v1beta1connect/organization_serviceuser.go
  • internal/api/v1beta1connect/organization_serviceuser_test.go
  • internal/store/postgres/org_serviceuser_repository.go
  • internal/store/postgres/org_serviceuser_repository_pg_test.go
  • internal/store/postgres/org_serviceuser_repository_test.go

Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review.


📝 Summary

Summary by CodeRabbit

  • New Features

    • Service-user listings support filtering, searching, sorting, and pagination.
    • Results include pagination metadata and stable ID-based tie-breaking.
    • Search and filtering include associated project names.
    • Organizations can be resolved by name when searching service users.
  • Bug Fixes

    • Service users without project policies remain visible with an empty projects list.
    • Soft-deleted projects and service users are excluded from results.
    • Unsupported fields, grouping, and invalid queries return clear validation errors.

Walkthrough

The service-user search path now uses explicit RQL metadata, shared query utilities, deterministic pagination, active-project aggregation, organization resolution, API validation, and PostgreSQL coverage.

Changes

Service-user search behavior

Layer / File(s) Summary
RQL contract and query preparation
core/aggregates/orgserviceuser/orgserviceuser.go, internal/store/postgres/org_serviceuser_repository.go, internal/store/postgres/org_serviceuser_repository_test.go
Fields use explicit RQL names and types. The repository delegates filtering, searching, sorting, and pagination to shared utilities. It adds deterministic ID tie-breaking, default pagination, project-title filtering and search, and bad-input validation for unsupported requests.
Project aggregation and repository validation
internal/store/postgres/org_serviceuser_repository.go, internal/store/postgres/org_serviceuser_repository_test.go, internal/store/postgres/org_serviceuser_repository_pg_test.go
The query aggregates distinct active projects, excludes soft-deleted projects, retains users without active project policies, and exposes project titles. Tests cover transformation, filtering, searching, sorting, pagination, duplicate role grants, and deleted rows.
API validation and test execution support
internal/api/v1beta1connect/organization_serviceuser.go, internal/api/v1beta1connect/organization_serviceuser_test.go, Makefile
The API validates RQL, resolves organizations before search, maps lookup errors to Connect codes, and preserves repository bad-input errors. The Go test timeout is increased to 600 seconds.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 0e475

The service-user search changes are mergeable after normal checks; no concrete unresolved behavior risk was identified.

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
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.

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.

@vercel

vercel Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
frontier Ready Ready Preview Sep 21, 2026 7:58am UTC

@coveralls

coveralls commented Sep 17, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 35575440706

Coverage increased (+0.2%) to 50.478%

Details

  • Coverage increased (+0.2%) from the base build.
  • Patch coverage: 5 uncovered changes across 2 files (61 of 66 lines covered, 92.42%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
internal/store/postgres/org_serviceuser_repository.go 54 50 92.59%
internal/api/v1beta1connect/organization_serviceuser.go 12 11 91.67%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 41099
Covered Lines: 20746
Line Coverage: 50.48%
Coverage Strength: 16.31 hits per line

💛 - Coveralls

@rohilsurana
rohilsurana marked this pull request as ready for review September 17, 2026 06:39
@rohilsurana
rohilsurana marked this pull request as draft September 17, 2026 06:54
@rohilsurana rohilsurana changed the title fix(store): list org service users that have no project policy fix(store): return every org service user and honour the requested sort Sep 17, 2026

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 2e96cca8-97bb-426a-92f9-84faefa5bf30

📥 Commits

Reviewing files that changed from the base of the PR and between 2f3eb24 and 61bb71c.

📒 Files selected for processing (2)
  • internal/store/postgres/org_serviceuser_repository.go
  • internal/store/postgres/org_serviceuser_repository_test.go

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Assert the returned pagination metadata. · org_serviceuser_repository.go:100-110

internal/store/postgres/org_serviceuser_repository.go:100-110
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the returned pagination metadata. The titles helper discards Search's Pagination, and the unit tests assert only the derived limit. Add assertions for the default limit and the requested offset and limit, including the explicit offset/limit path.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: ca4e3a1b-5273-4a01-82c9-33dd33edfb64

📥 Commits

Reviewing files that changed from the base of the PR and between 4fd4cd0 and a3d8650.

📒 Files selected for processing (6)
  • Makefile
  • core/aggregates/orgserviceuser/orgserviceuser.go
  • internal/api/v1beta1connect/organization_serviceuser.go
  • internal/store/postgres/org_serviceuser_repository.go
  • internal/store/postgres/org_serviceuser_repository_pg_test.go
  • internal/store/postgres/org_serviceuser_repository_test.go
💤 Files with no reviewable changes (1)
  • core/aggregates/orgserviceuser/orgserviceuser.go

Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review.

@rohilsurana

Copy link
Copy Markdown
Member Author

Re: Assert the returned pagination metadata (org_serviceuser_repository.go:100-110), which landed outside the diff range so it has no inline thread.

Fair point, fixed in d22f197.

The unit test now asserts the whole utils.Page rather than just the derived limit, so both offset and limit are pinned, including the explicit offset and limit cases:

assert.Equal(t, tt.wantPage, gotPage)

The integration suite gained TestPaginationMetadata, which goes through Search and asserts what actually comes back:

  • no limit given → reports Limit: 50, Offset: 0, and returns all 5 rows
  • Limit: 2, Offset: 2 → reports 2 and 2, and returns 2 rows
  • Limit: 2, Offset: 4 → reports 2 and 4, and returns the 1 remaining row

That last case matters for the admin UI, which decides whether to fetch another page by comparing the row count against the limit it reads off the response.

@rohilsurana

rohilsurana commented Sep 18, 2026 •

Copy link
Copy Markdown
Member Author

Endpoint comparison, run against a live server

I ran both endpoints side by side on a local Frontier (postgres and SpiceDB in docker) and recorded what each actually returned. Every row below is a real call, not a reading of the code.

The Search (main) column is the same request against a binary built from main, so the table doubles as a before and after.

Re-run and corrected on 18 Sep 2026, after the rebase. Branch at 0e47579, main at f840f22e. Six cells changed, and three rows were added. They are listed at the bottom.

Fixtures

One org, six service users, two projects. Everything except the state changes was created through the API so SpiceDB stays consistent.

Creating a service user always gives it a viewer policy on its org, so every row below has one unless it says otherwise. The policy on c-no-policy was then removed by hand, to get a service user with no policy at all.

service user project policies created_at state
a-with-project owner on a live project 2026-01-01 enabled
b-org-policy-only none, only the org policy 2026-06-01 enabled
c-no-policy none, and no org policy either 2026-03-01 enabled
d-deleted-project owner on a soft deleted project 2026-09-01 enabled
e-two-roles owner and viewer on the same project 2026-04-01 enabled
f-disabled none, only the org policy 2026-05-01 disabled

Which rows come back

# Case List Search (main) Search (this PR)
1 All service users in the org 6 a,b,c,d,e,f 3 a,d,e 6 a,b,c,d,e,f ✅
2 Has no policy at all (c) yes missing yes ✅
3 Has only an org policy (b) yes missing yes ✅
4 Is disabled (f) yes missing yes ✅
5 Org with no service users 200, 0 rows 200, 0 rows 200, 0 rows ✅

main dropped half the org. That is the original bug: the inner join required a policy on a project.

Response shape

# Case List Search (main) Search (this PR)
6 Fields per row id title org_id state metadata created_at updated_at id title org_id projects created_at unchanged
7 Returns state yes no no
8 Returns metadata / updated_at yes no no
9 Returns projects no yes yes
10 Soft deleted project listed for d n/a proj-gone shown excluded ✅
11 Two roles on one project (e) n/a listed twice listed once ✅

Rows 7 to 9 are a deliberate difference. The two endpoints answer different questions, and the proto messages are unchanged by this PR.

Query support (List takes only an org id, so it has no query surface)

# Case List Search (main) Search (this PR)
12 Sort created_at desc not supported ignored, a,d,e d,b,f,e,c,a, newest first ✅
13 Sort title desc not supported ignored, ascending a,d,e f,e,d,c,b,a ✅
14 Filter title ilike %policy% not supported 0 rows, b and c were already dropped b,c ✅
15 Filter created_at > 2026-04-15 not supported 400 'created_at' is not a valid field b,d,f ✅
16 Filter projects ilike %Live% not supported ignored, returns a,d,e a,e ✅
17 Search Live, a project title not supported 0 rows, it searches the title only a,e, covers project titles ✅
18 Pagination, limit 2 at offsets 0/2/4 returns all a,d | e | empty, only 3 rows to page a,b | c,d | e,f, no overlap ✅
19 No limit given returns all 6 3 rows, empty pagination block 6 rows, reports limit: 50

Rejections

# Case List Search (main) Search (this PR)
20 Filter on an unknown field n/a 400 400 'nonsense' is not a valid field
21 Filter with an unparsable date n/a 400, but only because created_at never resolved 400 not a valid ISO datetime string
22 Sort on a column the query does not select n/a 500 invalid sort field: state 400 'state' is not a valid sort key
23 group_by n/a 200, silently ignored 400 group_by is not supported

Addressing the organization

# Case List Search (main) Search (this PR)
24 Org given by name instead of id 6 rows 500, log says invalid input syntax for type uuid 6 rows ✅
25 Org that does not exist permission_denied 200, empty not_found ✅
26 Disabled organization failed_precondition 200, 0 rows, because of the join bug 200, 1 row
27 Soft deleted organization (deleted_at set) not_found 200, the org is never looked up not_found ✅
28 Organization deleted through the API permission_denied 200, the org is never looked up not_found ✅
29 Principal required get on the org platform superuser platform superuser

Rows 24 and 25 were found by this comparison and fixed in the PR. Search now resolves the org first, the way List already did.

Rows 27 and 28 are the soft delete check. Search resolves the org through GetRaw, which reads live rows only, so a deleted org gives not_found instead of an empty 200. The store also skips soft deleted service users, policies and projects.

Row 26 is deliberate. Search uses GetRaw, so a platform admin can still inspect a disabled org. List uses Get, which refuses. Blocking an admin from looking inside a disabled org seemed like the wrong call, but it is easy to change if you disagree.

What is left different on purpose

  • state, metadata and updated_at are on List only; projects is on Search only.
  • List accepts any caller with get on the org. Search requires a platform superuser.
  • List has no filtering, sorting or pagination at all.

What changed in this re-run

The earlier version of this comment had a few cells that were wrong or vague. They are corrected above:

  • Row 22: main does not ignore an unsupported sort. It returns a 500, invalid sort field: state. The old text said "ignored".
  • Row 26: main returns 200 with no rows for a disabled org, not 200 with rows. The single service user there has no project policy, so the join drops it.
  • Rows 14, 17, 18 and 19 said "n/a" or "untested" for main. They are now measured.
  • The fixture note about org policies was missing, so c-no-policy and f-disabled were described wrongly.
  • Rows 27 and 28, on deleted organizations, are new.

Everything else came out the same as before.

@rohilsurana
rohilsurana force-pushed the fix/search-org-service-users-missing-rows branch from 0e0cdac to 0e47579 Compare September 18, 2026 08:28
@rohilsurana

Copy link
Copy Markdown
Member Author

Deleted organizations are skipped, including on the admin API

Rebased onto main to pick up f840f22, which adds live() / fromLive() and makes user, organization and project reads skip soft-deleted rows.

What the admin search does now

The handler resolves the organization through orgService.GetRaw, which goes to OrganizationRepository.GetByID / GetByName. Both now use fromLive, so a soft-deleted organization simply is not found.

Verified against a live server, one call per row:

request result
live org rows returned
org with deleted_at set 404 not_found
same org addressed by name 404 not_found
org row hard deleted, service user rows left orphaned in the table 404 not_found
platform org 00000000-..., which has no row by design 404 not_found

The orphan row is the one that matters. 20260723120000_restrict_org_delete_with_serviceusers says orphaned service users can exist and are cleaned up out of band. I left one behind on purpose by dropping the org row with the trigger disabled, and the search still returns 404 rather than listing it. On main that request had no organization lookup at all, so those orphans came back as normal results.

The rest of the query

Every table the search touches carries a deleted_at, so each one is now filtered with the shared helper:

LEFT JOIN "policies" ON (... AND "policies"."deleted_at" IS NULL)
LEFT JOIN "projects" ON (... AND "projects"."deleted_at" IS NULL)
WHERE  "serviceusers"."org_id" = $3 AND "serviceusers"."deleted_at" IS NULL

The projects filter was already there, written by hand; it now uses live(TABLE_PROJECTS) so there is one definition of the rule. The policies and serviceusers filters are new.

Nothing sets deleted_at on those two tables today, since both are hard deleted, so this changes no current behaviour. It means the search stays correct if soft delete is turned on for them later, which is the direction main is moving.

TestSoftDeletedRowsAreSkipped in the postgres suite covers it: a soft-deleted service user disappears from the result, and a soft-deleted policy keeps its service user but drops the project it granted.

One behaviour change to be aware of

Searching the platform org id now returns 404 where it used to return the bootstrap superuser. The admin UI never does this. Its only use of that id is isZeroUUID in the audit log view, which uses it to decide not to render an organization link, and platform users have their own endpoint.

Still open

ListOrganizationServiceUsers also skips deleted organizations, because its handler resolves through orgService.Get which uses the same reads. But the plain ServiceUserRepository.List does not filter serviceusers.deleted_at. If soft delete is ever enabled for service users, that endpoint will show rows this one hides. It is shared by other callers, so it deserves its own change rather than being folded in here.

@rohilsurana
rohilsurana merged commit aabc1bb into main Sep 21, 2026
8 checks passed
@rohilsurana
rohilsurana deleted the fix/search-org-service-users-missing-rows branch September 21, 2026 08:06

This branch was successfully deployed

1 active deployment
Preview — bee0719b Deployed Sep 21, 2026 by vercel[bot]
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.

3 participants