Skip to content

feat: admin-only /api/users/twoFactor audit endpoints [DHIS2-20097] - #23925

Merged
netroms merged 8 commits into
masterfrom
fix/expose-twofactortype-on-users-api-DHIS2-20097
May 28, 2026
Merged

feat: admin-only /api/users/twoFactor audit endpoints [DHIS2-20097]#23925
netroms merged 8 commits into
masterfrom
fix/expose-twofactortype-on-users-api-DHIS2-20097

Conversation

@netroms

@netroms netroms commented May 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a superuser-only endpoint group for auditing 2FA enrolment across the user base. Replaces the earlier attempt that exposed twoFactorType directly on /api/users — the User resource ACL was too coarse, anyone who could read users would have seen every user's 2FA state.

  • GET /api/users/twoFactor/summary — totals, byType breakdown, privileged-user (ALL-authority) coverage stats. No cache (used rarely).
  • GET /api/users/twoFactor — per-user list, filterable by ?status=ALL|ENABLED|DISABLED and ?type=TOTP_ENABLED,EMAIL_ENABLED,… (multi-value).

Both endpoints are gated by @RequiresAuthority(anyOf = ALL). /api/me.twoFactorType is unaffected — it reads via MeDto, not the User Jackson stack.

JIRA: DHIS2-20097

Why

v42 removed every admin-readable surface for another user's 2FA state, blocking:

  • Incident response (which ALL-holders lack 2FA after a credential leak?)
  • Periodic 2FA-coverage audits
  • Pre-go-live onboarding hardening

AI Assisted.

netroms added 3 commits May 17, 2026 04:03
v42 dropped twoFactorEnabled and userCredentials.twoFA from the User
JSON schema, leaving no admin-readable surface for a user's 2FA state
on /api/users. This blocks incident response, periodic 2FA-coverage
audits and onboarding hardening.

Re-expose the field as a read-only TwoFactorType on /api/users and
/api/users/{uid}, mirroring the existing User.getName() READ_ONLY
pattern (Jackson + schema). Read access stays gated by the existing
User resource ACL; write access stays at the /api/2fa/* endpoints
only (READ_ONLY blocks PATCH/PUT side-channels).

AI Assisted.
…-20097]

FieldPathHelperTest#skipSharingFieldsExcludeCorrectFieldsTest asserts the
total number of properties on the User schema. Adding twoFactorType in
the previous commit bumped the count from 58 to 59.

AI Assisted.
Replace the JSON-layer exposure on /api/users with a dedicated
admin-only audit endpoint group. The User resource ACL is too coarse
for this field: anyone who can read the User resource would have seen
every other user's 2FA state. The new endpoints are gated by the ALL
authority (superuser) and keep /api/users clean.

- GET /api/users/twoFactor/summary -- totals, byType breakdown,
  privileged-user (ALL-authority) coverage stats. Computed live; no
  cache (used rarely).
- GET /api/users/twoFactor -- per-user list filterable by ?status=
  (ALL|ENABLED|DISABLED) and ?type= (multi-value of TwoFactorType).

Reverts the User.java / JsonUser.java / UserControllerTest.java
changes from the previous attempt and the FieldPathHelperTest count
bump. /api/me.twoFactorType is unaffected (it reads via MeDto, not
the User Jackson stack).

AI Assisted.
@netroms netroms changed the title fix: expose twoFactorType on /api/users endpoint [DHIS2-20097] feat: admin-only /api/users/twoFactor audit endpoints [DHIS2-20097] May 22, 2026
@netroms
netroms marked this pull request as ready for review May 22, 2026 20:39
netroms added 5 commits May 23, 2026 05:30
…097]

Replace the controller-side userService.getAllUsers() scan with a
TwoFactorAuditQueryService that aggregates counts and projects user
rows directly via JdbcTemplate. Avoids hydrating User entities and
their lazy userRoles collections, so a single privileged call no
longer pulls the full user-role-group graph into memory.

- countByType(): one GROUP BY against userinfo
- countPrivileged(): single join over userrolemembers + userroleauthorities
  with a FILTER aggregate for the missing-2FA subset
- count(filter) / list(filter, offset, limit): conditional WHERE built
  from status + type filters, paged DB-side with LIMIT/OFFSET

The list endpoint now returns a standard DHIS2 Pager wrapper and
accepts ?page, ?pageSize, ?paging=false. Default pageSize=50, max=1000.

Mirrors the JdbcStatisticsProvider pattern already used in
dhis-service-administration.

AI Assisted.
Two findings from adversarial review:

* The controller computed the DB offset from the raw request `page` while
  Pager's constructor silently clamps page to [1, pageCount]. Asking for
  page=999 returned an empty list with `pager.page` reporting a different
  page — a payload that contradicted itself. Build the Pager first and
  drive the query off `pager.getOffset()` / `pager.getPageSize()` so both
  metadata and rows agree.
* Although the Postgres column is NOT NULL today, the Hibernate mapping
  declares twofactortype nullable. Under three-valued SQL logic a stray
  NULL would slip past `... NOT IN ('TOTP_ENABLED','EMAIL_ENABLED')`
  (UNKNOWN, excluded) and corrupt every privileged/disabled count.
  Coalesce defensively in countByType, countPrivileged, and the
  status/type filter clauses.

Tests: new out-of-bounds-page case asserts pager.page matches pageCount
and the response carries the last page's rows (not an empty list).

AI Assisted.
Four findings from the Antigravity adversarial review:

* ORDER BY LOWER(username) defeated the unique index on username,
  forcing a full-table filesort. Removed the LOWER() wrapper since
  DHIS2 already enforces lowercase usernames at creation time.

* countByType / countPrivileged / count / list scanned all rows
  including disabled accounts and uncompleted invitations, skewing
  2FA coverage percentages and inflating the privileged-at-risk count.
  Added AND disabled = false AND invitation = false to every base
  WHERE clause.

* The list projection lacked email, disabled, and invitation fields,
  making it impossible for auditors to contact non-compliant users or
  distinguish active from deactivated accounts without a second query.
  Added all three to UserAuditRow and TwoFactorAuditEntry.

* paging=false set the SQL LIMIT to -1 (unbounded), risking OOM on
  large instances. Capped at UNPAGED_HARD_CEILING (10 000 rows).

AI Assisted.
…-20097]

The nested Status enum in TwoFactorAuditQueryService collided with
another Status type in the generated OpenAPI document, failing
OpenApiControllerTest. Renamed to TwoFactorAuditStatus.

AI Assisted.
@sonarqubecloud

Copy link
Copy Markdown

@netroms
netroms merged commit 9a972d6 into master May 28, 2026
18 checks passed
@netroms
netroms deleted the fix/expose-twofactortype-on-users-api-DHIS2-20097 branch May 28, 2026 11:08
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