From 54b4d67a9bf61c150c3ddf0223dcefa4c347dcec Mon Sep 17 00:00:00 2001 From: netroms Date: Thu, 28 May 2026 19:08:32 +0800 Subject: [PATCH] feat: admin-only /api/users/twoFactor audit endpoints [DHIS2-20097] (#23925) --- .../audit/TwoFactorAuditQueryService.java | 215 ++++++++++++++++++ .../UserTwoFactorAuditControllerTest.java | 193 ++++++++++++++++ .../user/UserTwoFactorAuditController.java | 153 +++++++++++++ 3 files changed, 561 insertions(+) create mode 100644 dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/security/twofa/audit/TwoFactorAuditQueryService.java create mode 100644 dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/UserTwoFactorAuditControllerTest.java create mode 100644 dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/UserTwoFactorAuditController.java diff --git a/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/security/twofa/audit/TwoFactorAuditQueryService.java b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/security/twofa/audit/TwoFactorAuditQueryService.java new file mode 100644 index 000000000000..9a5fa6b1c9b0 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/security/twofa/audit/TwoFactorAuditQueryService.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.security.twofa.audit; + +import java.util.ArrayList; +import java.util.Date; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import javax.annotation.CheckForNull; +import lombok.RequiredArgsConstructor; +import org.hisp.dhis.security.twofa.TwoFactorType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Native-SQL backed provider for the 2FA enrolment audit endpoints. Aggregates counts and lists + * users directly against the {@code userinfo} / {@code userrolemembers} / {@code + * userroleauthorities} tables, avoiding full-graph hydration of {@code User} entities and their + * lazy {@code userRoles} collections. + * + * @author Morten Svanaes + */ +@Service +@RequiredArgsConstructor +public class TwoFactorAuditQueryService { + + public enum TwoFactorAuditStatus { + ALL, + ENABLED, + DISABLED + } + + private static final String ENABLED_TYPES_SQL_LIST = "('TOTP_ENABLED','EMAIL_ENABLED')"; + + private static final String EFFECTIVE_TYPE_SQL = "COALESCE(twofactortype, 'NOT_ENABLED')"; + + // Only count accounts that can actually log in. + private static final String ACTIVE_ACCOUNT_FILTER = + " AND disabled = false AND invitation = false"; + + private final JdbcTemplate jdbcTemplate; + + /** Returns the row count of active users grouped by {@code twofactortype}. */ + public Map countByType() { + Map result = new EnumMap<>(TwoFactorType.class); + for (TwoFactorType type : TwoFactorType.values()) { + result.put(type, 0L); + } + jdbcTemplate.query( + "SELECT " + + EFFECTIVE_TYPE_SQL + + " AS effective_type, COUNT(*) FROM userinfo WHERE 1=1" + + ACTIVE_ACCOUNT_FILTER + + " GROUP BY " + + EFFECTIVE_TYPE_SQL, + rs -> { + String raw = rs.getString(1); + if (raw != null) { + try { + result.put(TwoFactorType.valueOf(raw), rs.getLong(2)); + } catch (IllegalArgumentException ignore) { + // Out-of-enum value in the column. + } + } + }); + return result; + } + + /** + * Returns the count of active users holding the {@code ALL} authority and how many of them have + * no active 2FA. + */ + public PrivilegedCounts countPrivileged() { + String sql = + "SELECT COUNT(DISTINCT urm.userid) AS with_all," + + " COUNT(DISTINCT urm.userid) FILTER (" + + " WHERE COALESCE(u.twofactortype, 'NOT_ENABLED') NOT IN " + + ENABLED_TYPES_SQL_LIST + + " ) AS with_all_missing" + + " FROM userrolemembers urm" + + " JOIN userroleauthorities ura ON ura.userroleid = urm.userroleid" + + " JOIN userinfo u ON u.userinfoid = urm.userid" + + " WHERE ura.authority = 'ALL'" + + " AND u.disabled = false AND u.invitation = false"; + PrivilegedCounts counts = + jdbcTemplate.queryForObject( + sql, (rs, n) -> new PrivilegedCounts(rs.getLong(1), rs.getLong(2))); + return counts == null ? new PrivilegedCounts(0L, 0L) : counts; + } + + /** Returns the number of active users matching the given filter. */ + public int count(TwoFactorAuditStatus status, @CheckForNull List types) { + StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM userinfo WHERE 1=1"); + sql.append(ACTIVE_ACCOUNT_FILTER); + List params = new ArrayList<>(); + appendStatusClause(sql, status); + appendTypeClause(sql, params, types); + Integer count = jdbcTemplate.queryForObject(sql.toString(), Integer.class, params.toArray()); + return count == null ? 0 : count; + } + + /** + * Returns the matching active user rows projected to the audit-row shape. {@code offset}/{@code + * limit} are applied DB-side via {@code OFFSET} / {@code LIMIT}; pass {@code limit < 0} to return + * all matches. + */ + public List list( + TwoFactorAuditStatus status, @CheckForNull List types, int offset, int limit) { + StringBuilder sql = + new StringBuilder( + "SELECT uid, username, name, twofactortype, lastlogin," + + " email, disabled, invitation FROM userinfo WHERE 1=1"); + sql.append(ACTIVE_ACCOUNT_FILTER); + List params = new ArrayList<>(); + appendStatusClause(sql, status); + appendTypeClause(sql, params, types); + sql.append(" ORDER BY username"); + if (limit >= 0) { + sql.append(" LIMIT ? OFFSET ?"); + params.add(limit); + params.add(Math.max(0, offset)); + } + return jdbcTemplate.query( + sql.toString(), + params.toArray(), + (rs, n) -> + new UserAuditRow( + rs.getString("uid"), + rs.getString("username"), + rs.getString("name"), + parseType(rs.getString("twofactortype")), + rs.getTimestamp("lastlogin"), + rs.getString("email"), + rs.getBoolean("disabled"), + rs.getBoolean("invitation"))); + } + + private static void appendStatusClause(StringBuilder sql, TwoFactorAuditStatus status) { + switch (status) { + case ENABLED -> + sql.append(" AND ") + .append(EFFECTIVE_TYPE_SQL) + .append(" IN ") + .append(ENABLED_TYPES_SQL_LIST); + case DISABLED -> + sql.append(" AND ") + .append(EFFECTIVE_TYPE_SQL) + .append(" NOT IN ") + .append(ENABLED_TYPES_SQL_LIST); + case ALL -> { + // no-op + } + } + } + + private static void appendTypeClause( + StringBuilder sql, List params, @CheckForNull List types) { + if (types == null || types.isEmpty()) return; + sql.append(" AND ").append(EFFECTIVE_TYPE_SQL).append(" IN ("); + for (int i = 0; i < types.size(); i++) { + sql.append(i == 0 ? "?" : ",?"); + params.add(types.get(i).name()); + } + sql.append(")"); + } + + private static TwoFactorType parseType(@CheckForNull String raw) { + if (raw == null) return TwoFactorType.NOT_ENABLED; + try { + return TwoFactorType.valueOf(raw); + } catch (IllegalArgumentException e) { + return TwoFactorType.NOT_ENABLED; + } + } + + public record PrivilegedCounts(long withAllAuthority, long withAllAuthorityMissing2FA) {} + + public record UserAuditRow( + String uid, + String username, + String name, + TwoFactorType twoFactorType, + Date lastLogin, + String email, + boolean disabled, + boolean invitation) {} +} diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/UserTwoFactorAuditControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/UserTwoFactorAuditControllerTest.java new file mode 100644 index 000000000000..1a664faec91b --- /dev/null +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/UserTwoFactorAuditControllerTest.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.webapi.controller; + +import static org.hisp.dhis.http.HttpStatus.FORBIDDEN; +import static org.hisp.dhis.http.HttpStatus.OK; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.hisp.dhis.jsontree.JsonList; +import org.hisp.dhis.jsontree.JsonObject; +import org.hisp.dhis.security.twofa.TwoFactorType; +import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase; +import org.hisp.dhis.user.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +class UserTwoFactorAuditControllerTest extends H2ControllerIntegrationTestBase { + + private User totpUser; + private User emailUser; + private User plainUser; + + @BeforeEach + void setUpUsers() { + totpUser = createUserWithTwoFactorType("totp", TwoFactorType.TOTP_ENABLED); + emailUser = createUserWithTwoFactorType("email", TwoFactorType.EMAIL_ENABLED); + plainUser = createUserWithTwoFactorType("plain", TwoFactorType.NOT_ENABLED); + } + + @Test + @DisplayName("GET /users/twoFactor/summary returns counts grouped by 2FA type") + void testSummary_returnsCountsByType() { + JsonObject summary = GET("/users/twoFactor/summary").content(OK); + + assertTrue( + summary.getNumber("totalUsers").integer() >= 3, + "summary must count at least the three seeded users"); + assertTrue( + summary.getNumber("enabled").integer() >= 2, + "totp + email users must be counted as enabled"); + assertTrue( + summary.getNumber("disabled").integer() >= 1, "plain user must be counted as disabled"); + + JsonObject byType = summary.getObject("byType"); + assertTrue(byType.getNumber("TOTP_ENABLED").integer() >= 1); + assertTrue(byType.getNumber("EMAIL_ENABLED").integer() >= 1); + assertTrue(byType.getNumber("NOT_ENABLED").integer() >= 1); + + JsonObject privileged = summary.getObject("privileged"); + assertTrue(privileged.getNumber("withAllAuthority").integer() >= 1, "admin counts as super"); + } + + @Test + @DisplayName("GET /users/twoFactor/summary is forbidden for non-superusers") + void testSummary_forbiddenForNonSuperuser() { + switchToNewUser("regular"); + assertEquals(FORBIDDEN, GET("/users/twoFactor/summary").status()); + } + + @Test + @DisplayName("GET /users/twoFactor lists all users with their 2FA type by default") + void testList_returnsAllByDefault() { + JsonObject body = GET("/users/twoFactor").content(OK); + JsonList users = body.getList("users", JsonObject.class); + + assertContainsUid(users, totpUser.getUid(), "TOTP_ENABLED"); + assertContainsUid(users, emailUser.getUid(), "EMAIL_ENABLED"); + assertContainsUid(users, plainUser.getUid(), "NOT_ENABLED"); + } + + @Test + @DisplayName("GET /users/twoFactor?status=ENABLED hides users without 2FA") + void testList_filterByStatusEnabled() { + JsonList users = + GET("/users/twoFactor?status=ENABLED").content(OK).getList("users", JsonObject.class); + + List uids = users.stream().map(u -> u.getString("id").string()).toList(); + assertTrue(uids.contains(totpUser.getUid())); + assertTrue(uids.contains(emailUser.getUid())); + assertTrue(uids.stream().noneMatch(plainUser.getUid()::equals)); + } + + @Test + @DisplayName("GET /users/twoFactor?status=DISABLED returns only users without 2FA") + void testList_filterByStatusDisabled() { + JsonList users = + GET("/users/twoFactor?status=DISABLED").content(OK).getList("users", JsonObject.class); + + List uids = users.stream().map(u -> u.getString("id").string()).toList(); + assertTrue(uids.contains(plainUser.getUid())); + assertTrue(uids.stream().noneMatch(totpUser.getUid()::equals)); + assertTrue(uids.stream().noneMatch(emailUser.getUid()::equals)); + } + + @Test + @DisplayName("GET /users/twoFactor?type=TOTP_ENABLED filters by 2FA type") + void testList_filterByType() { + JsonList users = + GET("/users/twoFactor?type=TOTP_ENABLED").content(OK).getList("users", JsonObject.class); + + List uids = users.stream().map(u -> u.getString("id").string()).toList(); + assertTrue(uids.contains(totpUser.getUid())); + assertTrue(uids.stream().noneMatch(emailUser.getUid()::equals)); + assertTrue(uids.stream().noneMatch(plainUser.getUid()::equals)); + } + + @Test + @DisplayName("GET /users/twoFactor is forbidden for non-superusers") + void testList_forbiddenForNonSuperuser() { + switchToNewUser("regular"); + assertEquals(FORBIDDEN, GET("/users/twoFactor").status()); + } + + @Test + @DisplayName("GET /users/twoFactor pages the result with pager total reflecting full match set") + void testList_paging() { + JsonObject body = GET("/users/twoFactor?pageSize=2&page=1").content(OK); + + JsonObject pager = body.getObject("pager"); + assertTrue( + pager.getNumber("total").integer() >= 3, + "pager.total must include every matching row, not just the page"); + assertEquals(2, pager.getNumber("pageSize").integer()); + assertEquals(1, pager.getNumber("page").integer()); + assertEquals(2, body.getList("users", JsonObject.class).size()); + } + + @Test + @DisplayName( + "GET /users/twoFactor with out-of-bounds page returns clamped page with matching data") + void testList_outOfBoundsPage() { + JsonObject body = GET("/users/twoFactor?pageSize=2&page=999").content(OK); + + JsonObject pager = body.getObject("pager"); + int reportedPage = pager.getNumber("page").integer(); + int pageCount = pager.getNumber("pageCount").integer(); + assertEquals( + pageCount, reportedPage, "Out-of-bounds page must be clamped to the last available page"); + assertTrue( + body.getList("users", JsonObject.class).size() > 0, + "Response body must contain the rows for the page reported by pager.page," + + " not an empty list mismatched against pager.page"); + } + + private User createUserWithTwoFactorType(String username, TwoFactorType type) { + User user = createUserWithAuth(username); + user.setTwoFactorType(type); + userService.updateUser(user); + manager.flush(); // ensure twofactortype is visible to JDBC reads inside the controller + return user; + } + + private static void assertContainsUid(JsonList users, String uid, String expectType) { + JsonObject match = + users.stream() + .filter(u -> uid.equals(u.getString("id").string())) + .findFirst() + .orElseThrow(() -> new AssertionError("user " + uid + " not in list")); + assertEquals(expectType, match.getString("twoFactorType").string()); + } +} diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/UserTwoFactorAuditController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/UserTwoFactorAuditController.java new file mode 100644 index 000000000000..79a268b9b0fe --- /dev/null +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/UserTwoFactorAuditController.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.webapi.controller.user; + +import static org.hisp.dhis.security.Authorities.ALL; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Date; +import java.util.List; +import java.util.Map; +import javax.annotation.CheckForNull; +import lombok.RequiredArgsConstructor; +import org.hisp.dhis.common.OpenApi; +import org.hisp.dhis.common.Pager; +import org.hisp.dhis.security.RequiresAuthority; +import org.hisp.dhis.security.twofa.TwoFactorType; +import org.hisp.dhis.security.twofa.audit.TwoFactorAuditQueryService; +import org.hisp.dhis.security.twofa.audit.TwoFactorAuditQueryService.PrivilegedCounts; +import org.hisp.dhis.security.twofa.audit.TwoFactorAuditQueryService.TwoFactorAuditStatus; +import org.hisp.dhis.security.twofa.audit.TwoFactorAuditQueryService.UserAuditRow; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Admin-only 2FA enrolment audit for the user base. Restricted to callers holding {@link + * org.hisp.dhis.security.Authorities#ALL}. All aggregation and filtering is delegated to {@link + * TwoFactorAuditQueryService}, which runs native SQL against the user tables — no User entities or + * lazy role collections are hydrated here. + * + * @author Morten Svanaes + */ +@OpenApi.Document( + group = OpenApi.Document.GROUP_QUERY, + classifiers = {"team:platform", "purpose:security"}) +@RestController +@RequestMapping("/api/users/twoFactor") +@RequiredArgsConstructor +@RequiresAuthority(anyOf = ALL) +public class UserTwoFactorAuditController { + + private static final int DEFAULT_PAGE_SIZE = 50; + private static final int MAX_PAGE_SIZE = 1000; + private static final int UNPAGED_HARD_CEILING = 10_000; + + private final TwoFactorAuditQueryService auditService; + + @GetMapping("/summary") + public TwoFactorAuditSummary getSummary() { + Map byType = auditService.countByType(); + long total = byType.values().stream().mapToLong(Long::longValue).sum(); + long enabled = + byType.getOrDefault(TwoFactorType.TOTP_ENABLED, 0L) + + byType.getOrDefault(TwoFactorType.EMAIL_ENABLED, 0L); + long disabled = total - enabled; + double coverage = total == 0 ? 0d : Math.round((double) enabled / total * 1000d) / 10d; + PrivilegedCounts privileged = auditService.countPrivileged(); + return new TwoFactorAuditSummary( + total, + enabled, + disabled, + coverage, + byType, + new PrivilegedUserStats( + privileged.withAllAuthority(), privileged.withAllAuthorityMissing2FA())); + } + + @GetMapping + public TwoFactorAuditList getList( + @RequestParam(required = false, defaultValue = "ALL") TwoFactorAuditStatus status, + @CheckForNull @RequestParam(required = false) List type, + @RequestParam(required = false, defaultValue = "true") boolean paging, + @RequestParam(required = false, defaultValue = "1") int page, + @RequestParam(required = false, defaultValue = "" + DEFAULT_PAGE_SIZE) int pageSize) { + int total = auditService.count(status, type); + int requestedPageSize = + paging + ? Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE) + : Math.max(1, Math.min(total, UNPAGED_HARD_CEILING)); + Pager pager = new Pager(page, total, requestedPageSize); + int offset = paging ? pager.getOffset() : 0; + int limit = paging ? pager.getPageSize() : UNPAGED_HARD_CEILING; + List entries = + auditService.list(status, type, offset, limit).stream() + .map(UserTwoFactorAuditController::toEntry) + .toList(); + return new TwoFactorAuditList(pager, entries); + } + + private static TwoFactorAuditEntry toEntry(UserAuditRow row) { + return new TwoFactorAuditEntry( + row.uid(), + row.username(), + row.name(), + row.twoFactorType(), + row.lastLogin(), + row.email(), + row.disabled(), + row.invitation()); + } + + public record TwoFactorAuditSummary( + @JsonProperty long totalUsers, + @JsonProperty long enabled, + @JsonProperty long disabled, + @JsonProperty double coveragePercent, + @JsonProperty Map byType, + @JsonProperty PrivilegedUserStats privileged) {} + + public record PrivilegedUserStats( + @JsonProperty long withAllAuthority, @JsonProperty long withAllAuthorityMissing2FA) {} + + public record TwoFactorAuditList( + @JsonProperty Pager pager, @JsonProperty List users) {} + + public record TwoFactorAuditEntry( + @JsonProperty String id, + @JsonProperty String username, + @JsonProperty String name, + @JsonProperty TwoFactorType twoFactorType, + @JsonProperty Date lastLogin, + @JsonProperty String email, + @JsonProperty boolean disabled, + @JsonProperty boolean invitation) {} +}