Skip to content

fix(membership): correct inflated total count in user and identity list queries#6901

Open
0xfandom wants to merge 2 commits into
Infisical:mainfrom
0xfandom:fix/membership-list-total-count-role-joins
Open

fix(membership): correct inflated total count in user and identity list queries#6901
0xfandom wants to merge 2 commits into
Infisical:mainfrom
0xfandom:fix/membership-list-total-count-role-joins

Conversation

@0xfandom

Copy link
Copy Markdown

Context

Fixes #5885.

The "All users" / "All identities" list endpoints reported an inflated total count. Both DALs derived their total from a window function evaluated over a query that joins membership_roles:

count("Membership"."actorUserId") OVER (PARTITION BY "Membership"."scopeOrgId") as total

Since the membership → membership_roles join is one-to-many, a member with N role assignments produces N rows, so the window counted role assignments, not distinct members. For an org with 5 users where 2 have multiple roles, the count came back as 8 instead of 5. This throws off pagination math (page counts, "showing X of Y") in the UI.

Two queries were still affected:

  • membership-user-dal.tsfindUsers
  • identity-org-dal.tssearchIdentities

PR #5850 already fixed the equivalent bug for the project identity membership list, and membership-group-dal.tsfindGroups was subsequently fixed the same way. This PR brings the remaining two siblings in line.

The fix replaces the window count with a separate countDistinct("Membership"."id") query over the same filtered set (before pagination is applied), exactly mirroring the pattern already used in findGroups. Pagination and result shape are unchanged.

Steps to verify the change

  1. In an org, give one or more users/identities multiple roles.
  2. Open Organization → Access Control → Users (and Identities).
  3. Before: the total/count shown is inflated by the number of extra role assignments.
  4. After: the total reflects the actual number of distinct users/identities.

Type

  • Fix

Checklist

  • Title follows the conventional commit format
  • Tested locally
  • Updated docs (if needed)
  • Updated CLAUDE.md files (if needed)
  • Read the contributing guide

…st queries

The org/project user list (`membership-user-dal.findUsers`) and the org
identity search (`identity-org-dal.searchIdentities`) derived their total
count from a window function (`count(...) OVER (PARTITION BY scopeOrgId)`)
evaluated over a query that joins `membership_roles`. Because that join is
one-to-many, a member with N role assignments produced N rows, so the count
returned the number of role assignments rather than the number of distinct
members. An org with 5 users where 2 have multiple roles reported 8.

Replace the window count with a separate `countDistinct(membership.id)` query
over the same filtered set, mirroring the approach already used in
`membership-group-dal.findGroups`. Pagination is unaffected.
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes an inflated totalCount returned by the "All users" and "All identities" list endpoints, where a window function evaluated over a one-to-many membership → membership_roles join was counting role assignments instead of distinct members.

  • identity-org-dal.ts (searchIdentities): Introduces a buildIdentitySearchBase() factory to avoid repeating the base query, then issues a separate countDistinct(\"Membership.id\") query over the same filtered set (before pagination) and uses that result as totalCount in place of the former window-function value.
  • membership-user-dal.ts (findUsers): Refactors the single monolithic query into a baseUserQuery (no distinct, no pagination), applies search filters to it, clones it once for the count (countDistinct) and once for the paginated ID subquery (distinct + limit/offset), then feeds those IDs into the full result query via whereIn. Both changes mirror the pattern already used by findGroups.

Confidence Score: 4/5

Safe to merge; the fix is logically correct and well-contained within two DAL functions.

Both changes correctly count distinct membership IDs before pagination and feed the result back as totalCount, eliminating the one-to-many inflation. The filter applied to the count query mirrors the filter on the search query, so filtered views also report accurate totals. The only observations are minor: the filter callback is duplicated verbatim for searchQuery and countQuery in identity-org-dal.ts, and the count and main queries run in serial where a Promise.all would halve the round-trips. Neither affects correctness.

No files require special attention; both changed files contain straightforward DAL query restructuring.

Important Files Changed

Filename Overview
backend/src/services/identity/identity-org-dal.ts Replaces the inflated window-function count with a separate countDistinct query via a buildIdentitySearchBase() factory; logic is correct but the filter callback is duplicated for searchQuery and countQuery, and the two queries run sequentially.
backend/src/services/membership-user/membership-user-dal.ts Splits the former single query into a baseUserQuery, a cloned count query (countDistinct), and a cloned paginated query; correctly applies filters before cloning and restores the distinct on the pagination branch. Logic is sound and mirrors the accepted findGroups pattern.

Reviews (1): Last reviewed commit: "fix(membership): correct inflated total ..." | Re-trigger Greptile

Comment on lines +452 to +461
buildKnexFilterForSearchResource(countQuery, searchFilter, (attr) => {
switch (attr) {
case "role":
return [`${TableName.Role}.slug`, `${TableName.MembershipRole}.role`];
case "name":
return `${TableName.Identity}.name`;
default:
throw new BadRequestError({ message: `Invalid ${String(attr)} provided` });
}
});

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 Duplicate filter callback inside searchFilter block

The mapper function passed to buildKnexFilterForSearchResource for countQuery (lines 452–461) is byte-for-byte identical to the one applied to searchQuery (lines 442–451). Extracting it into a shared const filterMapper defined once before the if (searchFilter) block would eliminate the duplication and make future changes (e.g. adding a new filterable attribute) easier to keep in sync.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +468 to +471
const [countResult] = (await countQuery.countDistinct(`${TableName.Membership}.id as count`)) as [
{ count: string | number }?
];
const totalCount = Number(countResult?.count ?? 0);

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 Count and main queries run sequentially, but they are independent

countQuery is awaited before query is even constructed (line 474), even though totalCount is not needed to build or execute the main query. Both queries share only orgId (a closure variable), so they could be issued concurrently with Promise.all. The current pattern doubles the number of serial round-trips to the database for every page load of the identities list. The same observation applies to findUsers in membership-user-dal.ts. This follows the same approach as findGroups, but a parallel execution would halve the latency here.

… query

Address review feedback on searchIdentities: extract the duplicated
buildKnexFilterForSearchResource attribute mapper into a single shared
function, and run the distinct-count query concurrently with the paginated
fetch via Promise.all since they are independent.
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.

fix: total count inflated by role JOINs in user and group membership list queries

1 participant