From 0cc538cff725276b870c56a8de6a10f057aa158f Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 15 May 2026 17:05:05 -0500 Subject: [PATCH 01/13] Add user list members endpoint (#1733) --- .../src/main/java/cwms/cda/ApiServlet.java | 4 + .../main/java/cwms/cda/api/Controllers.java | 1 + .../userlists/UserListMembersController.java | 90 +++++++++++++++++++ .../java/cwms/cda/data/dao/UserListDao.java | 80 +++++++++++++++++ .../dto/auth/userlists/UserListMember.java | 63 +++++++++++++ .../dto/auth/userlists/UserListMembers.java | 32 +++++++ 6 files changed, 270 insertions(+) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMembers.java diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index 57a679c47e..ea112f909d 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -105,6 +105,7 @@ import cwms.cda.api.auth.ApiKeyController; import cwms.cda.api.auth.users.UserProfileController; import cwms.cda.api.auth.users.UsersController; +import cwms.cda.api.auth.userlists.UserListMembersController; import cwms.cda.api.auth.users.roles.AddRoleController; import cwms.cda.api.auth.users.roles.DeleteRolesController; import cwms.cda.api.auth.users.roles.GetRolesController; @@ -672,6 +673,9 @@ private void addUserManagementHandlers() { String userProfilePath = "/user/profile"; get(userProfilePath, new UserProfileController(metrics), userRoles); cdaAccessManager.addCustomAuthorizer(userProfilePath, ApiServlet::hasAnyRole); + String userListMembersPath = "/user/list/{user-list-id}/members"; + get(userListMembersPath, new UserListMembersController(metrics), userRoles); + cdaAccessManager.addCustomAuthorizer(userListMembersPath, ApiServlet::hasAnyRole); post("/user/{user-name}/roles/{office-id}", new AddRoleController(metrics), adminRoles); delete("/user/{user-name}/roles/{office-id}", new DeleteRolesController(metrics), adminRoles); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java index ed05a37267..76f221e40f 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java @@ -232,6 +232,7 @@ public final class Controllers { public static final String PROJECT_LIKE = "project-like"; public static final String USERNAME_LIKE = "username-like"; + public static final String USER_LIST_ID = "user-list-id"; public static final String APPLICATION_ID = "application-id"; public static final String REVOKE_EXISTING = "revoke-existing"; public static final String REVOKE_TIMEOUT = "revoke-timeout"; diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java new file mode 100644 index 0000000000..655a51884f --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java @@ -0,0 +1,90 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.GET_ONE; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.USER_LIST_ID; +import static cwms.cda.data.dao.JooqDao.getDslContext; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.api.errors.RequiredQueryParameterException; +import cwms.cda.data.dao.UserListDao; +import cwms.cda.data.dto.Office; +import cwms.cda.data.dto.auth.userlists.UserListMembers; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.javalin.plugin.openapi.annotations.OpenApiSecurity; +import org.jooq.DSLContext; + +public final class UserListMembersController implements Handler { + public static final String TAG = "User Management"; + private final MetricRegistry metrics; + + public UserListMembersController(MetricRegistry metrics) { + this.metrics = metrics; + } + + private Timer.Context markAndTime(String subject) { + return Controllers.markAndTime(metrics, getClass().getName(), subject); + } + + @OpenApi( + pathParams = { + @OpenApiParam(name = USER_LIST_ID, required = true, + description = "The identifier of the user list to retrieve members for.") + }, + queryParams = { + @OpenApiParam(name = OFFICE, required = true, + description = "The office that owns the requested user list.") + }, + responses = { + @OpenApiResponse( + status = STATUS_200, + content = { + @OpenApiContent(from = UserListMembers.class, type = Formats.JSON) + } + ) + }, + security = { + @OpenApiSecurity(name = "gets overridden allows lock icon.") + }, + description = "Retrieve the members of a user list.", + method = HttpMethod.GET, + tags = {TAG} + ) + @Override + public void handle(Context ctx) { + try (final Timer.Context ignored = markAndTime(GET_ONE)) { + String office = ctx.queryParam(OFFICE); + if (office == null || office.isBlank()) { + throw new RequiredQueryParameterException(OFFICE); + } + + office = ctx.queryParamAsClass(OFFICE, String.class) + .check(Office::validOfficeNotNull, "Invalid office provided") + .get(); + + String userListId = ctx.pathParam(USER_LIST_ID); + DSLContext dsl = getDslContext(ctx); + UserListDao dao = new UserListDao(dsl); + UserListMembers members = dao.getMembers(office, userListId); + + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, UserListMembers.class); + String result = Formats.format(contentType, members); + + ctx.result(result); + ctx.contentType(contentType.toString()); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java new file mode 100644 index 0000000000..e40619a7f4 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java @@ -0,0 +1,80 @@ +package cwms.cda.data.dao; + +import static org.jooq.impl.DSL.field; +import static org.jooq.impl.DSL.name; +import static org.jooq.impl.DSL.selectOne; +import static org.jooq.impl.DSL.table; +import static org.jooq.impl.DSL.upper; + +import cwms.cda.api.errors.NotFoundException; +import cwms.cda.data.dto.auth.userlists.UserListMember; +import cwms.cda.data.dto.auth.userlists.UserListMembers; +import java.util.List; +import java.util.Optional; +import org.jooq.Condition; +import org.jooq.DSLContext; +import org.jooq.Field; +import org.jooq.Table; + +public final class UserListDao extends Dao { + + private final Table avUserListMembers = table(name("cwms_20", "av_user_list_members")).as("ulm"); + private final Table atUserLists = table(name("cwms_20", "at_user_lists")).as("ul"); + private final Table cwmsOffice = table(name("cwms_20", "cwms_office")).as("co"); + + public UserListDao(DSLContext dsl) { + super(dsl); + } + + @Override + public Optional getByUniqueName(String uniqueName, String office) { + return Optional.empty(); + } + + public UserListMembers getMembers(String officeId, String userListId) { + if (!userListExists(officeId, userListId)) { + throw new NotFoundException("User list not found: " + officeId + "/" + userListId); + } + + Field viewOfficeId = field(name(avUserListMembers.getName(), "office_id"), String.class); + Field viewUserListId = field(name(avUserListMembers.getName(), "user_list_id"), String.class); + Field viewUserId = field(name(avUserListMembers.getName(), "user_id"), String.class); + Field viewFullName = field(name(avUserListMembers.getName(), "full_name"), String.class); + Field viewEmail = field(name(avUserListMembers.getName(), "email"), String.class); + + List members = dsl.select(viewOfficeId, viewUserListId, viewUserId, viewFullName, + viewEmail) + .from(avUserListMembers) + .where(ignoreCaseEq(viewOfficeId, officeId)) + .and(ignoreCaseEq(viewUserListId, userListId)) + .orderBy(viewFullName.asc().nullsLast(), viewUserId.asc()) + .fetch(record -> new UserListMember( + record.get(viewOfficeId), + record.get(viewUserListId), + record.get(viewUserId), + record.get(viewFullName), + record.get(viewEmail) + )); + + return new UserListMembers(members); + } + + private boolean userListExists(String officeId, String userListId) { + Field listOfficeCode = field(name(atUserLists.getName(), "db_office_code"), Number.class); + Field listUserListId = field(name(atUserLists.getName(), "user_list_id"), String.class); + Field officeCode = field(name(cwmsOffice.getName(), "office_code"), Number.class); + Field officeName = field(name(cwmsOffice.getName(), "office_id"), String.class); + + return dsl.fetchExists( + selectOne() + .from(atUserLists) + .join(cwmsOffice).on(listOfficeCode.eq(officeCode)) + .where(ignoreCaseEq(officeName, officeId)) + .and(ignoreCaseEq(listUserListId, userListId)) + ); + } + + private static Condition ignoreCaseEq(Field field, String value) { + return upper(field).eq(value.toUpperCase()); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java new file mode 100644 index 0000000000..f0e2da8675 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java @@ -0,0 +1,63 @@ +package cwms.cda.data.dto.auth.userlists; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV1; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, + aliases = {Formats.DEFAULT, Formats.JSON}) +public final class UserListMember extends CwmsDTOBase { + + @JsonProperty(required = true) + @Schema(description = "The owning CWMS office identifier for the user list.") + private final String officeId; + + @JsonProperty(required = true) + @Schema(description = "The identifier of the user list.") + private final String userListId; + + @JsonProperty(required = true) + @Schema(description = "The user identifier for the member.") + private final String userId; + + @Schema(description = "The user's display name.") + private final String fullName; + + @Schema(description = "The user's email address.") + private final String email; + + public UserListMember(String officeId, String userListId, String userId, String fullName, + String email) { + this.officeId = officeId; + this.userListId = userListId; + this.userId = userId; + this.fullName = fullName; + this.email = email; + } + + public String getOfficeId() { + return officeId; + } + + public String getUserListId() { + return userListId; + } + + public String getUserId() { + return userId; + } + + public String getFullName() { + return fullName; + } + + public String getEmail() { + return email; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMembers.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMembers.java new file mode 100644 index 0000000000..a0525442d8 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMembers.java @@ -0,0 +1,32 @@ +package cwms.cda.data.dto.auth.userlists; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV1; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Collections; +import java.util.List; + +@JsonRootName("user-list-members") +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, + aliases = {Formats.DEFAULT, Formats.JSON}) +public final class UserListMembers extends CwmsDTOBase { + + @JsonProperty(required = true) + @Schema(description = "Members in the requested user list.") + private final List members; + + public UserListMembers(List members) { + this.members = List.copyOf(members); + } + + public List getMembers() { + return Collections.unmodifiableList(members); + } +} From 71a0d61f0917276b49956805aad482abd5641616 Mon Sep 17 00:00:00 2001 From: Charles Graham Date: Tue, 2 Jun 2026 16:37:35 -0500 Subject: [PATCH 02/13] Add user list metadata endpoint --- .../src/main/java/cwms/cda/ApiServlet.java | 4 + .../auth/userlists/UserListController.java | 93 +++++++++ .../java/cwms/cda/data/dao/UserListDao.java | 51 +++-- .../cda/data/dto/auth/userlists/UserList.java | 72 +++++++ .../api/users/UserListControllerTestIT.java | 184 ++++++++++++++++++ 5 files changed, 392 insertions(+), 12 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java create mode 100644 cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index ea112f909d..0490d1d7bb 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -105,6 +105,7 @@ import cwms.cda.api.auth.ApiKeyController; import cwms.cda.api.auth.users.UserProfileController; import cwms.cda.api.auth.users.UsersController; +import cwms.cda.api.auth.userlists.UserListController; import cwms.cda.api.auth.userlists.UserListMembersController; import cwms.cda.api.auth.users.roles.AddRoleController; import cwms.cda.api.auth.users.roles.DeleteRolesController; @@ -673,6 +674,9 @@ private void addUserManagementHandlers() { String userProfilePath = "/user/profile"; get(userProfilePath, new UserProfileController(metrics), userRoles); cdaAccessManager.addCustomAuthorizer(userProfilePath, ApiServlet::hasAnyRole); + String userListPath = "/user/list/{user-list-id}"; + get(userListPath, new UserListController(metrics), userRoles); + cdaAccessManager.addCustomAuthorizer(userListPath, ApiServlet::hasAnyRole); String userListMembersPath = "/user/list/{user-list-id}/members"; get(userListMembersPath, new UserListMembersController(metrics), userRoles); cdaAccessManager.addCustomAuthorizer(userListMembersPath, ApiServlet::hasAnyRole); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java new file mode 100644 index 0000000000..ef4dfbfea4 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java @@ -0,0 +1,93 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.GET_ONE; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.USER_LIST_ID; +import static cwms.cda.data.dao.JooqDao.getDslContext; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.api.errors.NotFoundException; +import cwms.cda.api.errors.RequiredQueryParameterException; +import cwms.cda.data.dao.UserListDao; +import cwms.cda.data.dto.Office; +import cwms.cda.data.dto.auth.userlists.UserList; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.javalin.plugin.openapi.annotations.OpenApiSecurity; +import org.jooq.DSLContext; + +public final class UserListController implements Handler { + public static final String TAG = "User Management"; + private final MetricRegistry metrics; + + public UserListController(MetricRegistry metrics) { + this.metrics = metrics; + } + + private Timer.Context markAndTime(String subject) { + return Controllers.markAndTime(metrics, getClass().getName(), subject); + } + + @OpenApi( + pathParams = { + @OpenApiParam(name = USER_LIST_ID, required = true, + description = "The identifier of the user list to retrieve.") + }, + queryParams = { + @OpenApiParam(name = OFFICE, required = true, + description = "The office that owns the requested user list.") + }, + responses = { + @OpenApiResponse( + status = STATUS_200, + content = { + @OpenApiContent(from = UserList.class, type = Formats.JSON) + } + ) + }, + security = { + @OpenApiSecurity(name = "gets overridden allows lock icon.") + }, + description = "Retrieve user list metadata.", + method = HttpMethod.GET, + tags = {TAG} + ) + @Override + public void handle(Context ctx) { + try (final Timer.Context ignored = markAndTime(GET_ONE)) { + String office = ctx.queryParam(OFFICE); + if (office == null || office.isBlank()) { + throw new RequiredQueryParameterException(OFFICE); + } + + final String officeId = ctx.queryParamAsClass(OFFICE, String.class) + .check(Office::validOfficeNotNull, "Invalid office provided") + .get(); + + String userListId = ctx.pathParam(USER_LIST_ID); + DSLContext dsl = getDslContext(ctx); + UserListDao dao = new UserListDao(dsl); + UserList userList = dao.getUserList(officeId, userListId) + .orElseThrow(() -> new NotFoundException("User list not found: " + + officeId + "/" + userListId)); + + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, UserList.class); + String result = Formats.format(contentType, userList); + + ctx.result(result); + ctx.contentType(contentType.toString()); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java index e40619a7f4..dc49d6dd7c 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java @@ -7,8 +7,10 @@ import static org.jooq.impl.DSL.upper; import cwms.cda.api.errors.NotFoundException; +import cwms.cda.data.dto.auth.userlists.UserList; import cwms.cda.data.dto.auth.userlists.UserListMember; import cwms.cda.data.dto.auth.userlists.UserListMembers; +import java.sql.Timestamp; import java.util.List; import java.util.Optional; import org.jooq.Condition; @@ -18,9 +20,9 @@ public final class UserListDao extends Dao { - private final Table avUserListMembers = table(name("cwms_20", "av_user_list_members")).as("ulm"); - private final Table atUserLists = table(name("cwms_20", "at_user_lists")).as("ul"); - private final Table cwmsOffice = table(name("cwms_20", "cwms_office")).as("co"); + private final Table avUserListMembers = table(name("CWMS_20", "AV_USER_LIST_MEMBERS")).as("ulm"); + private final Table atUserLists = table(name("CWMS_20", "AT_USER_LISTS")).as("ul"); + private final Table cwmsOffice = table(name("CWMS_20", "CWMS_OFFICE")).as("co"); public UserListDao(DSLContext dsl) { super(dsl); @@ -31,16 +33,41 @@ public Optional getByUniqueName(String uniqueName, String office return Optional.empty(); } + public Optional getUserList(String officeId, String userListId) { + Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); + Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); + Field listDescription = field(name(atUserLists.getName(), "USER_LIST_DESC"), String.class); + Field listOwner = field(name(atUserLists.getName(), "OWNED_BY_USERID"), String.class); + Field listCreatedAt = field(name(atUserLists.getName(), "CREATED_AT"), Timestamp.class); + Field listUpdatedAt = field(name(atUserLists.getName(), "UPDATED_AT"), Timestamp.class); + Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); + Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); + + return dsl.select(officeName, listUserListId, listDescription, listOwner, listCreatedAt, listUpdatedAt) + .from(atUserLists) + .join(cwmsOffice).on(listOfficeCode.eq(officeCode)) + .where(ignoreCaseEq(officeName, officeId)) + .and(ignoreCaseEq(listUserListId, userListId)) + .fetchOptional(record -> new UserList( + record.get(officeName), + record.get(listUserListId), + record.get(listDescription), + record.get(listOwner), + record.get(listCreatedAt).toInstant(), + Optional.ofNullable(record.get(listUpdatedAt)).map(Timestamp::toInstant).orElse(null) + )); + } + public UserListMembers getMembers(String officeId, String userListId) { if (!userListExists(officeId, userListId)) { throw new NotFoundException("User list not found: " + officeId + "/" + userListId); } - Field viewOfficeId = field(name(avUserListMembers.getName(), "office_id"), String.class); - Field viewUserListId = field(name(avUserListMembers.getName(), "user_list_id"), String.class); - Field viewUserId = field(name(avUserListMembers.getName(), "user_id"), String.class); - Field viewFullName = field(name(avUserListMembers.getName(), "full_name"), String.class); - Field viewEmail = field(name(avUserListMembers.getName(), "email"), String.class); + Field viewOfficeId = field(name(avUserListMembers.getName(), "OFFICE_ID"), String.class); + Field viewUserListId = field(name(avUserListMembers.getName(), "USER_LIST_ID"), String.class); + Field viewUserId = field(name(avUserListMembers.getName(), "USER_ID"), String.class); + Field viewFullName = field(name(avUserListMembers.getName(), "FULL_NAME"), String.class); + Field viewEmail = field(name(avUserListMembers.getName(), "EMAIL"), String.class); List members = dsl.select(viewOfficeId, viewUserListId, viewUserId, viewFullName, viewEmail) @@ -60,10 +87,10 @@ public UserListMembers getMembers(String officeId, String userListId) { } private boolean userListExists(String officeId, String userListId) { - Field listOfficeCode = field(name(atUserLists.getName(), "db_office_code"), Number.class); - Field listUserListId = field(name(atUserLists.getName(), "user_list_id"), String.class); - Field officeCode = field(name(cwmsOffice.getName(), "office_code"), Number.class); - Field officeName = field(name(cwmsOffice.getName(), "office_id"), String.class); + Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); + Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); + Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); + Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); return dsl.fetchExists( selectOne() diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java new file mode 100644 index 0000000000..177522a571 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java @@ -0,0 +1,72 @@ +package cwms.cda.data.dto.auth.userlists; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV1; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; + +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, + aliases = {Formats.DEFAULT, Formats.JSON}) +public final class UserList extends CwmsDTOBase { + + @JsonProperty(required = true) + @Schema(description = "The owning CWMS office identifier for the user list.") + private final String officeId; + + @JsonProperty(required = true) + @Schema(description = "The identifier of the user list.") + private final String userListId; + + @Schema(description = "The user list description.") + private final String description; + + @Schema(description = "The user id of the list owner.") + private final String ownedByUserId; + + @JsonProperty(required = true) + @Schema(description = "The time the user list was created.") + private final Instant createdAt; + + @Schema(description = "The time the user list was last updated.") + private final Instant updatedAt; + + public UserList(String officeId, String userListId, String description, String ownedByUserId, + Instant createdAt, Instant updatedAt) { + this.officeId = officeId; + this.userListId = userListId; + this.description = description; + this.ownedByUserId = ownedByUserId; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public String getOfficeId() { + return officeId; + } + + public String getUserListId() { + return userListId; + } + + public String getDescription() { + return description; + } + + public String getOwnedByUserId() { + return ownedByUserId; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java new file mode 100644 index 0000000000..00839a2694 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java @@ -0,0 +1,184 @@ +package cwms.cda.api.users; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +import cwms.cda.api.DataApiTestIT; +import fixtures.CwmsDataApiSetupCallback; +import fixtures.KeyCloakExtension; +import fixtures.TestAccounts; +import fixtures.users.UserSpecSource; +import fixtures.users.annotation.AuthType; +import io.javalin.http.HttpCode; +import io.restassured.filter.log.LogDetail; +import io.restassured.specification.RequestSpecification; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import mil.army.usace.hec.test.database.CwmsDatabaseContainer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ArgumentsSource; + +@Tag("integration") +@ExtendWith(KeyCloakExtension.class) +public final class UserListControllerTestIT extends DataApiTestIT { + private static final String OFFICE = "SPK"; + private static final String USER_LIST_ID = "USER_LIST_CONTROLLER_TEST"; + private static final String USER_LIST_DESC = "Integration test user list"; + + @BeforeAll + static void ensureUserListSchema() throws SQLException { + CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); + db.connection(c -> { + try { + executeIgnoreObjectExists(c, + "CREATE TABLE AT_USER_LISTS (" + + "DB_OFFICE_CODE NUMBER NOT NULL, " + + "USER_LIST_ID VARCHAR2(128) NOT NULL, " + + "USER_LIST_DESC VARCHAR2(1024), " + + "OWNED_BY_USERID VARCHAR2(128), " + + "CREATED_AT TIMESTAMP DEFAULT current_timestamp NOT NULL, " + + "UPDATED_AT TIMESTAMP)"); + executeIgnoreObjectExists(c, + "CREATE UNIQUE INDEX AT_USER_LISTS_PK ON AT_USER_LISTS (USER_LIST_ID)"); + executeIgnoreObjectExists(c, + "ALTER TABLE AT_USER_LISTS ADD CONSTRAINT AT_USER_LISTS_PK " + + "PRIMARY KEY (USER_LIST_ID) USING INDEX AT_USER_LISTS_PK"); + executeIgnoreObjectExists(c, + "ALTER TABLE AT_USER_LISTS ADD CONSTRAINT AT_USER_LISTS_FK1 " + + "FOREIGN KEY (DB_OFFICE_CODE) REFERENCES CWMS_OFFICE (OFFICE_CODE)"); + executeIgnoreObjectExists(c, + "CREATE OR REPLACE TRIGGER AT_USER_LISTS_TRIG " + + "BEFORE INSERT OR UPDATE ON AT_USER_LISTS " + + "REFERENCING NEW AS new OLD AS old FOR EACH ROW BEGIN " + + ":new.user_list_id := UPPER(:new.user_list_id); " + + ":new.owned_by_userid := UPPER(:new.owned_by_userid); " + + ":new.updated_at := current_timestamp; END;"); + executeIgnoreObjectExists(c, + "CREATE TABLE AT_USER_LIST_MEMBERS (" + + "USER_LIST_ID VARCHAR2(128) NOT NULL, " + + "USERID VARCHAR2(128) NOT NULL, " + + "ADD_DATE TIMESTAMP DEFAULT current_timestamp NOT NULL, " + + "ADDED_BY_USERID VARCHAR2(128))"); + executeIgnoreObjectExists(c, + "CREATE UNIQUE INDEX AT_USER_LIST_MEMBERS_PK " + + "ON AT_USER_LIST_MEMBERS (USER_LIST_ID, USERID)"); + executeIgnoreObjectExists(c, + "ALTER TABLE AT_USER_LIST_MEMBERS ADD CONSTRAINT AT_USER_LIST_MEMBERS_PK " + + "PRIMARY KEY (USER_LIST_ID, USERID) USING INDEX AT_USER_LIST_MEMBERS_PK"); + executeIgnoreObjectExists(c, + "ALTER TABLE AT_USER_LIST_MEMBERS ADD CONSTRAINT AT_USER_LIST_MEMBERS_FK1 " + + "FOREIGN KEY (USER_LIST_ID) REFERENCES AT_USER_LISTS (USER_LIST_ID)"); + executeIgnoreObjectExists(c, + "CREATE OR REPLACE VIEW AV_USER_LIST_MEMBERS (OFFICE_ID, DB_OFFICE_CODE, " + + "USER_LIST_ID, USER_LIST_DESC, OWNED_BY_USERID, USER_ID, FULL_NAME, " + + "EMAIL, OFFICE_SYMBOL, MEMBER_OFFICE_ID, ADD_DATE, ADDED_BY_USERID) AS " + + "SELECT o.office_id, l.db_office_code, l.user_list_id, l.user_list_desc, " + + "l.owned_by_userid, u.user_id, u.full_name, u.email, u.office_symbol, " + + "u.office_id AS member_office_id, m.add_date, m.added_by_userid " + + "FROM at_user_lists l " + + "JOIN cwms_office o ON o.office_code = l.db_office_code " + + "JOIN at_user_list_members m ON m.user_list_id = l.user_list_id " + + "JOIN av_cwms_user u ON u.user_id = m.userid"); + executeIgnoreInsufficientPrivilege(c, "GRANT SELECT ON AT_USER_LISTS TO CWMS_USER"); + executeIgnoreInsufficientPrivilege(c, "GRANT SELECT ON AT_USER_LIST_MEMBERS TO CWMS_USER"); + executeIgnoreInsufficientPrivilege(c, "GRANT SELECT ON AV_USER_LIST_MEMBERS TO CWMS_USER"); + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + }, "cwms_20"); + } + + @BeforeEach + void createUserList() throws SQLException { + CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); + db.connection(c -> { + try { + try (PreparedStatement deleteMembers = c.prepareStatement( + "DELETE FROM AT_USER_LIST_MEMBERS WHERE USER_LIST_ID = ?"); + PreparedStatement deleteList = c.prepareStatement( + "DELETE FROM AT_USER_LISTS WHERE USER_LIST_ID = ?"); + PreparedStatement insertList = c.prepareStatement( + "INSERT INTO AT_USER_LISTS (DB_OFFICE_CODE, USER_LIST_ID, USER_LIST_DESC) " + + "SELECT OFFICE_CODE, ?, ? FROM CWMS_OFFICE WHERE OFFICE_ID = ?")) { + deleteMembers.setString(1, USER_LIST_ID); + deleteMembers.executeUpdate(); + deleteList.setString(1, USER_LIST_ID); + deleteList.executeUpdate(); + insertList.setString(1, USER_LIST_ID); + insertList.setString(2, USER_LIST_DESC); + insertList.setString(3, OFFICE); + insertList.executeUpdate(); + } + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + }, "cwms_20"); + } + + @AfterEach + void deleteUserList() throws SQLException { + CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); + db.connection(c -> { + try { + try (PreparedStatement deleteMembers = c.prepareStatement( + "DELETE FROM AT_USER_LIST_MEMBERS WHERE USER_LIST_ID = ?"); + PreparedStatement deleteList = c.prepareStatement( + "DELETE FROM AT_USER_LISTS WHERE USER_LIST_ID = ?")) { + deleteMembers.setString(1, USER_LIST_ID); + deleteMembers.executeUpdate(); + deleteList.setString(1, USER_LIST_ID); + deleteList.executeUpdate(); + } + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + }, "cwms_20"); + } + + @ParameterizedTest + @ArgumentsSource(UserSpecSource.class) + @AuthType(user = TestAccounts.KeyUser.SPK_NORMAL2) + void test_get_user_list(String authType, TestAccounts.KeyUser theUser, RequestSpecification authSpec) { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .spec(authSpec) + .queryParam("office", OFFICE) + .when() + .get("/user/list/{user-list-id}", USER_LIST_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpCode.OK.getStatus())) + .body("office-id", equalTo(OFFICE)) + .body("user-list-id", equalTo(USER_LIST_ID)) + .body("description", equalTo(USER_LIST_DESC)); + } + + private static void executeIgnoreObjectExists(java.sql.Connection c, String sql) throws SQLException { + try (PreparedStatement stmt = c.prepareStatement(sql)) { + stmt.execute(); + } catch (SQLException ex) { + String message = ex.getMessage(); + if (message == null || !(message.contains("ORA-00955") || message.contains("ORA-02260") + || message.contains("ORA-02261") || message.contains("ORA-02275"))) { + throw ex; + } + } + } + + private static void executeIgnoreInsufficientPrivilege(java.sql.Connection c, String sql) throws SQLException { + try (PreparedStatement stmt = c.prepareStatement(sql)) { + stmt.execute(); + } catch (SQLException ex) { + String message = ex.getMessage(); + if (message == null || !message.contains("ORA-01031")) { + throw ex; + } + } + } +} From 99c6b99b0a07e1c4f3946dd71a0a03f571824c45 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 24 Jul 2026 15:01:38 -0500 Subject: [PATCH 03/13] Gate user list endpoints by schema support Signed-off-by: Charles Graham, SWT --- .../src/main/java/cwms/cda/ApiServlet.java | 27 +++++++++++++++---- .../auth/userlists/UserListController.java | 3 +++ .../api/auth/userlists/UserListFeature.java | 27 +++++++++++++++++++ .../userlists/UserListMembersController.java | 3 +++ .../java/cwms/cda/features/CdaFeatures.java | 6 +++-- .../cwms/cda/helpers/DatabaseHelpers.java | 1 + .../src/main/resources/features.properties | 1 + 7 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListFeature.java diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index 0490d1d7bb..c4c7531ea9 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -107,6 +107,7 @@ import cwms.cda.api.auth.users.UsersController; import cwms.cda.api.auth.userlists.UserListController; import cwms.cda.api.auth.userlists.UserListMembersController; +import cwms.cda.features.CdaFeatures; import cwms.cda.api.auth.users.roles.AddRoleController; import cwms.cda.api.auth.users.roles.DeleteRolesController; import cwms.cda.api.auth.users.roles.GetRolesController; @@ -189,6 +190,7 @@ import io.javalin.apibuilder.CrudHandlerKt; import io.javalin.core.JavalinConfig; import io.javalin.core.security.RouteRole; +import io.javalin.http.Context; import io.javalin.core.util.Header; import io.javalin.core.validation.JavalinValidation; import io.javalin.http.BadRequestResponse; @@ -231,6 +233,7 @@ import javax.sql.DataSource; import org.apache.http.entity.ContentType; import org.jetbrains.annotations.NotNull; +import org.togglz.core.context.FeatureContext; import org.jooq.exception.DataAccessException; import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; @@ -674,15 +677,29 @@ private void addUserManagementHandlers() { String userProfilePath = "/user/profile"; get(userProfilePath, new UserProfileController(metrics), userRoles); cdaAccessManager.addCustomAuthorizer(userProfilePath, ApiServlet::hasAnyRole); + addUserListHandlers(userRoles); + post("/user/{user-name}/roles/{office-id}", new AddRoleController(metrics), adminRoles); + delete("/user/{user-name}/roles/{office-id}", new DeleteRolesController(metrics), adminRoles); + + } + + private void addUserListHandlers(RouteRole[] userRoles) { String userListPath = "/user/list/{user-list-id}"; - get(userListPath, new UserListController(metrics), userRoles); - cdaAccessManager.addCustomAuthorizer(userListPath, ApiServlet::hasAnyRole); String userListMembersPath = "/user/list/{user-list-id}/members"; - get(userListMembersPath, new UserListMembersController(metrics), userRoles); + if (FeatureContext.getFeatureManager().isActive(CdaFeatures.USER_LISTS)) { + get(userListPath, new UserListController(metrics), userRoles); + get(userListMembersPath, new UserListMembersController(metrics), userRoles); + } else { + get(userListPath, this::userListsUnsupported, userRoles); + get(userListMembersPath, this::userListsUnsupported, userRoles); + } + cdaAccessManager.addCustomAuthorizer(userListPath, ApiServlet::hasAnyRole); cdaAccessManager.addCustomAuthorizer(userListMembersPath, ApiServlet::hasAnyRole); - post("/user/{user-name}/roles/{office-id}", new AddRoleController(metrics), adminRoles); - delete("/user/{user-name}/roles/{office-id}", new DeleteRolesController(metrics), adminRoles); + } + private void userListsUnsupported(Context ctx) { + ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED) + .json(new CdaError("User lists are not enabled for this CDA deployment.")); } private static Boolean hasAnyRole(DataApiPrincipal p, Set roles) throws MissingRolesException { diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java index ef4dfbfea4..dfaa9cc136 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java @@ -77,6 +77,9 @@ public void handle(Context ctx) { String userListId = ctx.pathParam(USER_LIST_ID); DSLContext dsl = getDslContext(ctx); + if (!UserListFeature.requireSupported(ctx, dsl)) { + return; + } UserListDao dao = new UserListDao(dsl); UserList userList = dao.getUserList(officeId, userListId) .orElseThrow(() -> new NotFoundException("User list not found: " diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListFeature.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListFeature.java new file mode 100644 index 0000000000..fd0b07b90a --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListFeature.java @@ -0,0 +1,27 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.helpers.DatabaseHelpers.SCHEMA_VERSION.V2026_07_16; + +import cwms.cda.api.errors.CdaError; +import cwms.cda.data.dao.Dao; +import io.javalin.http.Context; +import java.net.HttpURLConnection; +import org.jooq.DSLContext; + +public final class UserListFeature { + public static final String UNSUPPORTED_MESSAGE = + "User lists require CWMS database schema 26.07.16 or newer."; + + private UserListFeature() { + } + + public static boolean requireSupported(Context ctx, DSLContext dsl) { + int version = Dao.versionAsInteger(Dao.getVersion(dsl)); + if (version < V2026_07_16.numeric()) { + ctx.status(HttpURLConnection.HTTP_NOT_IMPLEMENTED) + .json(new CdaError(UNSUPPORTED_MESSAGE)); + return false; + } + return true; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java index 655a51884f..957583ab6b 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java @@ -76,6 +76,9 @@ public void handle(Context ctx) { String userListId = ctx.pathParam(USER_LIST_ID); DSLContext dsl = getDslContext(ctx); + if (!UserListFeature.requireSupported(ctx, dsl)) { + return; + } UserListDao dao = new UserListDao(dsl); UserListMembers members = dao.getMembers(office, userListId); diff --git a/cwms-data-api/src/main/java/cwms/cda/features/CdaFeatures.java b/cwms-data-api/src/main/java/cwms/cda/features/CdaFeatures.java index 7dd1fe2d79..85a471e7cf 100644 --- a/cwms-data-api/src/main/java/cwms/cda/features/CdaFeatures.java +++ b/cwms-data-api/src/main/java/cwms/cda/features/CdaFeatures.java @@ -9,7 +9,9 @@ public enum CdaFeatures implements Feature { @Label("Re-enable non-hash key support") AUTH_RE_ENABLE_NON_HASH_KEY_SUPPORT, - @Label("Include stack traces in JSON error responses for authorized debug requests") - INCLUDE_ERROR_STACK_TRACES + INCLUDE_ERROR_STACK_TRACES, + + @Label("Enable office-scoped reusable user lists") + USER_LISTS } diff --git a/cwms-data-api/src/main/java/cwms/cda/helpers/DatabaseHelpers.java b/cwms-data-api/src/main/java/cwms/cda/helpers/DatabaseHelpers.java index 5cd60f43b7..9ba0b77d95 100644 --- a/cwms-data-api/src/main/java/cwms/cda/helpers/DatabaseHelpers.java +++ b/cwms-data-api/src/main/java/cwms/cda/helpers/DatabaseHelpers.java @@ -7,6 +7,7 @@ public class DatabaseHelpers { public enum SCHEMA_VERSION { V2025_07_01(250701, "25.07.01"), + V2026_07_16(260716, "26.07.16"), LATEST_DEV(LATEST_SCHEMA, "99.99.99"), BYPASS(-1, "Bypass") ; diff --git a/cwms-data-api/src/main/resources/features.properties b/cwms-data-api/src/main/resources/features.properties index 2932a7cca4..156a27e477 100644 --- a/cwms-data-api/src/main/resources/features.properties +++ b/cwms-data-api/src/main/resources/features.properties @@ -1,3 +1,4 @@ USE_OBJECT_STORAGE_BLOBS=false AUTH_RE_ENABLE_NON_HASH_KEY_SUPPORT=false INCLUDE_ERROR_STACK_TRACES=true +USER_LISTS=false From 3fd984c8eb74771446ef52a8de77c3d680ca9f76 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 24 Jul 2026 15:03:57 -0500 Subject: [PATCH 04/13] Complete user list metadata CRUD Signed-off-by: Charles Graham, SWT --- .../src/main/java/cwms/cda/ApiServlet.java | 11 ++ .../auth/userlists/UserListController.java | 43 +++++++ .../api/auth/userlists/UserListSupport.java | 44 +++++++ .../auth/userlists/UserListsController.java | 64 ++++++++++ .../java/cwms/cda/data/dao/UserListDao.java | 109 ++++++++++++++++++ .../dto/auth/userlists/UserListInput.java | 34 ++++++ .../data/dto/auth/userlists/UserLists.java | 29 +++++ 7 files changed, 334 insertions(+) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListSupport.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListsController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListInput.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserLists.java diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index c4c7531ea9..15e3a2899a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -107,6 +107,7 @@ import cwms.cda.api.auth.users.UsersController; import cwms.cda.api.auth.userlists.UserListController; import cwms.cda.api.auth.userlists.UserListMembersController; +import cwms.cda.api.auth.userlists.UserListsController; import cwms.cda.features.CdaFeatures; import cwms.cda.api.auth.users.roles.AddRoleController; import cwms.cda.api.auth.users.roles.DeleteRolesController; @@ -684,15 +685,25 @@ private void addUserManagementHandlers() { } private void addUserListHandlers(RouteRole[] userRoles) { + String userListsPath = "/user/list"; String userListPath = "/user/list/{user-list-id}"; String userListMembersPath = "/user/list/{user-list-id}/members"; if (FeatureContext.getFeatureManager().isActive(CdaFeatures.USER_LISTS)) { + get(userListsPath, new UserListsController(metrics), userRoles); + post(userListsPath, new UserListsController(metrics), userRoles); get(userListPath, new UserListController(metrics), userRoles); + patch(userListPath, new UserListController(metrics), userRoles); + delete(userListPath, new UserListController(metrics), userRoles); get(userListMembersPath, new UserListMembersController(metrics), userRoles); } else { + get(userListsPath, this::userListsUnsupported, userRoles); + post(userListsPath, this::userListsUnsupported, userRoles); get(userListPath, this::userListsUnsupported, userRoles); + patch(userListPath, this::userListsUnsupported, userRoles); + delete(userListPath, this::userListsUnsupported, userRoles); get(userListMembersPath, this::userListsUnsupported, userRoles); } + cdaAccessManager.addCustomAuthorizer(userListsPath, ApiServlet::hasAnyRole); cdaAccessManager.addCustomAuthorizer(userListPath, ApiServlet::hasAnyRole); cdaAccessManager.addCustomAuthorizer(userListMembersPath, ApiServlet::hasAnyRole); } diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java index dfaa9cc136..2491bc42d6 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java @@ -1,6 +1,8 @@ package cwms.cda.api.auth.userlists; import static cwms.cda.api.Controllers.GET_ONE; +import static cwms.cda.api.Controllers.DELETE; +import static cwms.cda.api.Controllers.UPDATE; import static cwms.cda.api.Controllers.OFFICE; import static cwms.cda.api.Controllers.STATUS_200; import static cwms.cda.api.Controllers.USER_LIST_ID; @@ -14,11 +16,13 @@ import cwms.cda.data.dao.UserListDao; import cwms.cda.data.dto.Office; import cwms.cda.data.dto.auth.userlists.UserList; +import cwms.cda.data.dto.auth.userlists.UserListInput; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; import io.javalin.core.util.Header; import io.javalin.http.Context; import io.javalin.http.Handler; +import io.javalin.http.HttpCode; import io.javalin.plugin.openapi.annotations.HttpMethod; import io.javalin.plugin.openapi.annotations.OpenApi; import io.javalin.plugin.openapi.annotations.OpenApiContent; @@ -65,6 +69,16 @@ private Timer.Context markAndTime(String subject) { ) @Override public void handle(Context ctx) { + if ("PATCH".equals(ctx.method())) { + update(ctx); + } else if ("DELETE".equals(ctx.method())) { + delete(ctx); + } else { + get(ctx); + } + } + + private void get(Context ctx) { try (final Timer.Context ignored = markAndTime(GET_ONE)) { String office = ctx.queryParam(OFFICE); if (office == null || office.isBlank()) { @@ -93,4 +107,33 @@ public void handle(Context ctx) { ctx.contentType(contentType.toString()); } } + + private void update(Context ctx) { + try (final Timer.Context ignored = markAndTime(UPDATE)) { + String office = UserListSupport.requiredOffice(ctx); + DSLContext dsl = UserListSupport.requireFeature(ctx); + if (dsl == null) { + return; + } + UserListDao dao = new UserListDao(dsl); + UserListSupport.requireOfficeAdmin(ctx, dao, office); + UserListInput input = ctx.bodyAsClass(UserListInput.class); + ctx.json(dao.updateUserList(office, ctx.pathParam(USER_LIST_ID), + input.getDescription())); + } + } + + private void delete(Context ctx) { + try (final Timer.Context ignored = markAndTime(DELETE)) { + String office = UserListSupport.requiredOffice(ctx); + DSLContext dsl = UserListSupport.requireFeature(ctx); + if (dsl == null) { + return; + } + UserListDao dao = new UserListDao(dsl); + UserListSupport.requireOfficeAdmin(ctx, dao, office); + dao.deleteUserList(office, ctx.pathParam(USER_LIST_ID)); + ctx.status(HttpCode.NO_CONTENT); + } + } } diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListSupport.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListSupport.java new file mode 100644 index 0000000000..da635351c6 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListSupport.java @@ -0,0 +1,44 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.data.dao.JooqDao.getDslContext; + +import cwms.cda.api.errors.RequiredQueryParameterException; +import cwms.cda.data.dao.AuthDao; +import cwms.cda.data.dao.UserListDao; +import cwms.cda.data.dto.Office; +import cwms.cda.security.DataApiPrincipal; +import io.javalin.http.Context; +import io.javalin.http.ForbiddenResponse; +import org.jooq.DSLContext; + +final class UserListSupport { + private UserListSupport() { + } + + static String requiredOffice(Context ctx) { + String office = ctx.queryParam(OFFICE); + if (office == null || office.isBlank()) { + throw new RequiredQueryParameterException(OFFICE); + } + return ctx.queryParamAsClass(OFFICE, String.class) + .check(Office::validOfficeNotNull, "Invalid office provided") + .get(); + } + + static DSLContext requireFeature(Context ctx) { + DSLContext dsl = getDslContext(ctx); + return UserListFeature.requireSupported(ctx, dsl) ? dsl : null; + } + + static DataApiPrincipal principal(Context ctx) { + return ctx.attribute(AuthDao.DATA_API_PRINCIPAL); + } + + static void requireOfficeAdmin(Context ctx, UserListDao dao, String office) { + DataApiPrincipal principal = principal(ctx); + if (principal == null || !dao.isOfficeUserAdmin(principal.getName(), office)) { + throw new ForbiddenResponse("CWMS User Admins access is required for office " + office); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListsController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListsController.java new file mode 100644 index 0000000000..d270fd212e --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListsController.java @@ -0,0 +1,64 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.CREATE; +import static cwms.cda.api.Controllers.GET_ALL; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.data.dao.UserListDao; +import cwms.cda.data.dto.auth.userlists.UserList; +import cwms.cda.data.dto.auth.userlists.UserListInput; +import cwms.cda.data.dto.auth.userlists.UserLists; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.http.HttpCode; +import org.jooq.DSLContext; + +public final class UserListsController implements Handler { + private final MetricRegistry metrics; + + public UserListsController(MetricRegistry metrics) { + this.metrics = metrics; + } + + @Override + public void handle(Context ctx) { + if ("GET".equals(ctx.method())) { + getAll(ctx); + } else { + create(ctx); + } + } + + private void getAll(Context ctx) { + try (Timer.Context ignored = Controllers.markAndTime(metrics, getClass().getName(), GET_ALL)) { + String office = UserListSupport.requiredOffice(ctx); + DSLContext dsl = UserListSupport.requireFeature(ctx); + if (dsl == null) { + return; + } + ctx.json(new UserLists(new UserListDao(dsl).getUserLists(office))); + } + } + + private void create(Context ctx) { + try (Timer.Context ignored = Controllers.markAndTime(metrics, getClass().getName(), CREATE)) { + UserListInput input = ctx.bodyAsClass(UserListInput.class); + String office = input.getOfficeId(); + if (office == null || office.isBlank() || input.getUserListId() == null + || input.getUserListId().isBlank()) { + throw new IllegalArgumentException("office-id and user-list-id are required"); + } + DSLContext dsl = UserListSupport.requireFeature(ctx); + if (dsl == null) { + return; + } + UserListDao dao = new UserListDao(dsl); + UserListSupport.requireOfficeAdmin(ctx, dao, office); + UserList created = dao.createUserList(office, input.getUserListId(), + input.getDescription(), UserListSupport.principal(ctx).getName()); + ctx.status(HttpCode.CREATED).json(created); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java index dc49d6dd7c..d70be830ff 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java @@ -23,6 +23,8 @@ public final class UserListDao extends Dao { private final Table avUserListMembers = table(name("CWMS_20", "AV_USER_LIST_MEMBERS")).as("ulm"); private final Table atUserLists = table(name("CWMS_20", "AT_USER_LISTS")).as("ul"); private final Table cwmsOffice = table(name("CWMS_20", "CWMS_OFFICE")).as("co"); + private final Table atSecUsers = table(name("CWMS_20", "AT_SEC_USERS")).as("su"); + private final Table atSecUserGroups = table(name("CWMS_20", "AT_SEC_USER_GROUPS")).as("sg"); public UserListDao(DSLContext dsl) { super(dsl); @@ -58,6 +60,113 @@ public Optional getUserList(String officeId, String userListId) { )); } + public List getUserLists(String officeId) { + Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); + Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); + Field listDescription = field(name(atUserLists.getName(), "USER_LIST_DESC"), String.class); + Field listOwner = field(name(atUserLists.getName(), "OWNED_BY_USERID"), String.class); + Field listCreatedAt = field(name(atUserLists.getName(), "CREATED_AT"), Timestamp.class); + Field listUpdatedAt = field(name(atUserLists.getName(), "UPDATED_AT"), Timestamp.class); + Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); + Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); + + return dsl.select(officeName, listUserListId, listDescription, listOwner, + listCreatedAt, listUpdatedAt) + .from(atUserLists) + .join(cwmsOffice).on(listOfficeCode.eq(officeCode)) + .where(ignoreCaseEq(officeName, officeId)) + .orderBy(listUserListId) + .fetch(record -> new UserList( + record.get(officeName), + record.get(listUserListId), + record.get(listDescription), + record.get(listOwner), + record.get(listCreatedAt).toInstant(), + Optional.ofNullable(record.get(listUpdatedAt)) + .map(Timestamp::toInstant).orElse(null) + )); + } + + public UserList createUserList(String officeId, String userListId, String description, + String owner) { + Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); + Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); + Field listDescription = field(name(atUserLists.getName(), "USER_LIST_DESC"), String.class); + Field listOwner = field(name(atUserLists.getName(), "OWNED_BY_USERID"), String.class); + Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); + Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); + + Long resolvedOfficeCode = dsl.select(officeCode) + .from(cwmsOffice) + .where(ignoreCaseEq(officeName, officeId)) + .fetchOptional(officeCode) + .orElseThrow(() -> new NotFoundException("Office not found: " + officeId)); + dsl.insertInto(atUserLists) + .columns(listOfficeCode, listUserListId, listDescription, listOwner) + .values(resolvedOfficeCode, userListId.toUpperCase(), description, owner.toUpperCase()) + .execute(); + return getUserList(officeId, userListId) + .orElseThrow(() -> new NotFoundException("Created user list was not found")); + } + + public UserList updateUserList(String officeId, String userListId, String description) { + Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); + Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); + Field listDescription = field(name(atUserLists.getName(), "USER_LIST_DESC"), String.class); + Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); + Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); + Long resolvedOfficeCode = dsl.select(officeCode).from(cwmsOffice) + .where(ignoreCaseEq(officeName, officeId)).fetchOne(officeCode); + int changed = dsl.update(atUserLists) + .set(listDescription, description) + .where(listOfficeCode.eq(resolvedOfficeCode)) + .and(ignoreCaseEq(listUserListId, userListId)) + .execute(); + if (changed == 0) { + throw new NotFoundException("User list not found: " + officeId + "/" + userListId); + } + return getUserList(officeId, userListId).orElseThrow(); + } + + public void deleteUserList(String officeId, String userListId) { + Field memberListId = field(name("AT_USER_LIST_MEMBERS", "USER_LIST_ID"), String.class); + Table members = table(name("CWMS_20", "AT_USER_LIST_MEMBERS")); + dsl.deleteFrom(members).where(ignoreCaseEq(memberListId, userListId)).execute(); + + Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); + Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); + Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); + Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); + Long resolvedOfficeCode = dsl.select(officeCode).from(cwmsOffice) + .where(ignoreCaseEq(officeName, officeId)).fetchOne(officeCode); + int deleted = dsl.deleteFrom(atUserLists) + .where(listOfficeCode.eq(resolvedOfficeCode)) + .and(ignoreCaseEq(listUserListId, userListId)) + .execute(); + if (deleted == 0) { + throw new NotFoundException("User list not found: " + officeId + "/" + userListId); + } + } + + public boolean isOfficeUserAdmin(String username, String officeId) { + Field securityUsername = field(name(atSecUsers.getName(), "USERNAME"), String.class); + Field securityOfficeCode = field(name(atSecUsers.getName(), "DB_OFFICE_CODE"), Long.class); + Field securityGroupCode = field(name(atSecUsers.getName(), "USER_GROUP_CODE"), Long.class); + Field groupOfficeCode = field(name(atSecUserGroups.getName(), "DB_OFFICE_CODE"), Long.class); + Field groupCode = field(name(atSecUserGroups.getName(), "USER_GROUP_CODE"), Long.class); + Field groupId = field(name(atSecUserGroups.getName(), "USER_GROUP_ID"), String.class); + Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); + Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); + return dsl.fetchExists(selectOne() + .from(atSecUsers) + .join(atSecUserGroups).on(securityOfficeCode.eq(groupOfficeCode) + .and(securityGroupCode.eq(groupCode))) + .join(cwmsOffice).on(securityOfficeCode.eq(officeCode)) + .where(ignoreCaseEq(securityUsername, username)) + .and(ignoreCaseEq(officeName, officeId)) + .and(ignoreCaseEq(groupId, "CWMS User Admins"))); + } + public UserListMembers getMembers(String officeId, String userListId) { if (!userListExists(officeId, userListId)) { throw new NotFoundException("User list not found: " + officeId + "/" + userListId); diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListInput.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListInput.java new file mode 100644 index 0000000000..570c7cda5d --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListInput.java @@ -0,0 +1,34 @@ +package cwms.cda.data.dto.auth.userlists; + +public final class UserListInput { + private String officeId; + private String userListId; + private String description; + + public UserListInput() { + } + + public String getOfficeId() { + return officeId; + } + + public void setOfficeId(String officeId) { + this.officeId = officeId; + } + + public String getUserListId() { + return userListId; + } + + public void setUserListId(String userListId) { + this.userListId = userListId; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserLists.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserLists.java new file mode 100644 index 0000000000..3677b99163 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserLists.java @@ -0,0 +1,29 @@ +package cwms.cda.data.dto.auth.userlists; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV1; +import java.util.Collections; +import java.util.List; + +@JsonRootName("user-lists") +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, + aliases = {Formats.DEFAULT, Formats.JSON}) +public final class UserLists extends CwmsDTOBase { + @JsonProperty(required = true) + private final List userLists; + + public UserLists(List userLists) { + this.userLists = List.copyOf(userLists); + } + + public List getUserLists() { + return Collections.unmodifiableList(userLists); + } +} From 31f00dc3616f5cdbacd442b7ec319cf594a7672e Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 24 Jul 2026 15:05:08 -0500 Subject: [PATCH 05/13] Add user list membership management Signed-off-by: Charles Graham, SWT --- .../src/main/java/cwms/cda/ApiServlet.java | 7 +++ .../userlists/UserListMemberController.java | 38 ++++++++++++++++ .../userlists/UserListMembersController.java | 30 +++++++++++++ .../java/cwms/cda/data/dao/UserListDao.java | 43 +++++++++++++++++++ .../auth/userlists/UserListMemberInput.java | 16 +++++++ 5 files changed, 134 insertions(+) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMemberController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMemberInput.java diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index 15e3a2899a..add97c5f18 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -107,6 +107,7 @@ import cwms.cda.api.auth.users.UsersController; import cwms.cda.api.auth.userlists.UserListController; import cwms.cda.api.auth.userlists.UserListMembersController; +import cwms.cda.api.auth.userlists.UserListMemberController; import cwms.cda.api.auth.userlists.UserListsController; import cwms.cda.features.CdaFeatures; import cwms.cda.api.auth.users.roles.AddRoleController; @@ -688,6 +689,7 @@ private void addUserListHandlers(RouteRole[] userRoles) { String userListsPath = "/user/list"; String userListPath = "/user/list/{user-list-id}"; String userListMembersPath = "/user/list/{user-list-id}/members"; + String userListMemberPath = "/user/list/{user-list-id}/members/{user-id}"; if (FeatureContext.getFeatureManager().isActive(CdaFeatures.USER_LISTS)) { get(userListsPath, new UserListsController(metrics), userRoles); post(userListsPath, new UserListsController(metrics), userRoles); @@ -695,6 +697,8 @@ private void addUserListHandlers(RouteRole[] userRoles) { patch(userListPath, new UserListController(metrics), userRoles); delete(userListPath, new UserListController(metrics), userRoles); get(userListMembersPath, new UserListMembersController(metrics), userRoles); + post(userListMembersPath, new UserListMembersController(metrics), userRoles); + delete(userListMemberPath, new UserListMemberController(metrics), userRoles); } else { get(userListsPath, this::userListsUnsupported, userRoles); post(userListsPath, this::userListsUnsupported, userRoles); @@ -702,10 +706,13 @@ private void addUserListHandlers(RouteRole[] userRoles) { patch(userListPath, this::userListsUnsupported, userRoles); delete(userListPath, this::userListsUnsupported, userRoles); get(userListMembersPath, this::userListsUnsupported, userRoles); + post(userListMembersPath, this::userListsUnsupported, userRoles); + delete(userListMemberPath, this::userListsUnsupported, userRoles); } cdaAccessManager.addCustomAuthorizer(userListsPath, ApiServlet::hasAnyRole); cdaAccessManager.addCustomAuthorizer(userListPath, ApiServlet::hasAnyRole); cdaAccessManager.addCustomAuthorizer(userListMembersPath, ApiServlet::hasAnyRole); + cdaAccessManager.addCustomAuthorizer(userListMemberPath, ApiServlet::hasAnyRole); } private void userListsUnsupported(Context ctx) { diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMemberController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMemberController.java new file mode 100644 index 0000000000..21ae2cd373 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMemberController.java @@ -0,0 +1,38 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.DELETE; +import static cwms.cda.api.Controllers.USER_ID; +import static cwms.cda.api.Controllers.USER_LIST_ID; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.data.dao.UserListDao; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.http.HttpCode; +import org.jooq.DSLContext; + +public final class UserListMemberController implements Handler { + private final MetricRegistry metrics; + + public UserListMemberController(MetricRegistry metrics) { + this.metrics = metrics; + } + + @Override + public void handle(Context ctx) { + try (Timer.Context ignored = Controllers.markAndTime( + metrics, getClass().getName(), DELETE)) { + String office = UserListSupport.requiredOffice(ctx); + DSLContext dsl = UserListSupport.requireFeature(ctx); + if (dsl == null) { + return; + } + UserListDao dao = new UserListDao(dsl); + UserListSupport.requireOfficeAdmin(ctx, dao, office); + dao.removeMember(office, ctx.pathParam(USER_LIST_ID), ctx.pathParam(USER_ID)); + ctx.status(HttpCode.NO_CONTENT); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java index 957583ab6b..9f9d92452a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java @@ -1,6 +1,7 @@ package cwms.cda.api.auth.userlists; import static cwms.cda.api.Controllers.GET_ONE; +import static cwms.cda.api.Controllers.CREATE; import static cwms.cda.api.Controllers.OFFICE; import static cwms.cda.api.Controllers.STATUS_200; import static cwms.cda.api.Controllers.USER_LIST_ID; @@ -13,11 +14,13 @@ import cwms.cda.data.dao.UserListDao; import cwms.cda.data.dto.Office; import cwms.cda.data.dto.auth.userlists.UserListMembers; +import cwms.cda.data.dto.auth.userlists.UserListMemberInput; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; import io.javalin.core.util.Header; import io.javalin.http.Context; import io.javalin.http.Handler; +import io.javalin.http.HttpCode; import io.javalin.plugin.openapi.annotations.HttpMethod; import io.javalin.plugin.openapi.annotations.OpenApi; import io.javalin.plugin.openapi.annotations.OpenApiContent; @@ -64,6 +67,14 @@ private Timer.Context markAndTime(String subject) { ) @Override public void handle(Context ctx) { + if ("POST".equals(ctx.method())) { + create(ctx); + } else { + get(ctx); + } + } + + private void get(Context ctx) { try (final Timer.Context ignored = markAndTime(GET_ONE)) { String office = ctx.queryParam(OFFICE); if (office == null || office.isBlank()) { @@ -90,4 +101,23 @@ public void handle(Context ctx) { ctx.contentType(contentType.toString()); } } + + private void create(Context ctx) { + try (final Timer.Context ignored = markAndTime(CREATE)) { + String office = UserListSupport.requiredOffice(ctx); + DSLContext dsl = UserListSupport.requireFeature(ctx); + if (dsl == null) { + return; + } + UserListDao dao = new UserListDao(dsl); + UserListSupport.requireOfficeAdmin(ctx, dao, office); + UserListMemberInput input = ctx.bodyAsClass(UserListMemberInput.class); + if (input.getUserId() == null || input.getUserId().isBlank()) { + throw new IllegalArgumentException("user-id is required"); + } + ctx.status(HttpCode.CREATED).json(dao.addMember( + office, ctx.pathParam(USER_LIST_ID), input.getUserId(), + UserListSupport.principal(ctx).getName())); + } + } } diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java index d70be830ff..fd31378344 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java @@ -195,6 +195,49 @@ public UserListMembers getMembers(String officeId, String userListId) { return new UserListMembers(members); } + public UserListMember addMember(String officeId, String userListId, String userId, + String addedBy) { + if (!userListExists(officeId, userListId)) { + throw new NotFoundException("User list not found: " + officeId + "/" + userListId); + } + Table users = table(name("CWMS_20", "AT_SEC_CWMS_USERS")).as("cu"); + Field existingUserId = field(name(users.getName(), "USERID"), String.class); + boolean userExists = dsl.fetchExists(selectOne().from(users) + .where(ignoreCaseEq(existingUserId, userId))); + if (!userExists) { + throw new NotFoundException("CWMS user not found: " + userId); + } + + Table members = table(name("CWMS_20", "AT_USER_LIST_MEMBERS")).as("ulm_write"); + Field memberListId = field(name(members.getName(), "USER_LIST_ID"), String.class); + Field memberUserId = field(name(members.getName(), "USERID"), String.class); + Field memberAddedBy = field(name(members.getName(), "ADDED_BY_USERID"), String.class); + dsl.insertInto(members) + .columns(memberListId, memberUserId, memberAddedBy) + .values(userListId.toUpperCase(), userId.toUpperCase(), addedBy.toUpperCase()) + .execute(); + return getMembers(officeId, userListId).getMembers().stream() + .filter(member -> member.getUserId().equalsIgnoreCase(userId)) + .findFirst() + .orElseThrow(() -> new NotFoundException("Added member was not found")); + } + + public void removeMember(String officeId, String userListId, String userId) { + if (!userListExists(officeId, userListId)) { + throw new NotFoundException("User list not found: " + officeId + "/" + userListId); + } + Table members = table(name("CWMS_20", "AT_USER_LIST_MEMBERS")).as("ulm_write"); + Field memberListId = field(name(members.getName(), "USER_LIST_ID"), String.class); + Field memberUserId = field(name(members.getName(), "USERID"), String.class); + int deleted = dsl.deleteFrom(members) + .where(ignoreCaseEq(memberListId, userListId)) + .and(ignoreCaseEq(memberUserId, userId)) + .execute(); + if (deleted == 0) { + throw new NotFoundException("User list member not found: " + userId); + } + } + private boolean userListExists(String officeId, String userListId) { Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMemberInput.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMemberInput.java new file mode 100644 index 0000000000..22c71b4eac --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMemberInput.java @@ -0,0 +1,16 @@ +package cwms.cda.data.dto.auth.userlists; + +public final class UserListMemberInput { + private String userId; + + public UserListMemberInput() { + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } +} From f7bdd16f0d13c18d51d460a79703ac6d598c57db Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 24 Jul 2026 15:06:02 -0500 Subject: [PATCH 06/13] Document CDA user list architecture Signed-off-by: Charles Graham, SWT --- docs/source/decisions/0013-cda-user-lists.md | 80 ++++++++++++++++++++ docs/source/decisions/index.rst | 3 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 docs/source/decisions/0013-cda-user-lists.md diff --git a/docs/source/decisions/0013-cda-user-lists.md b/docs/source/decisions/0013-cda-user-lists.md new file mode 100644 index 0000000000..0bfb69634c --- /dev/null +++ b/docs/source/decisions/0013-cda-user-lists.md @@ -0,0 +1,80 @@ +# CDA User Lists + +| Status | Proposed | +| :-- | :-- | +| **ADR #** | 0013 | +| **Author** | Charles Graham | +| **Date** | 2026-07-24 | +| **Supersedes** | N/A | + +## Context + +CWMS Data API clients need reusable, named collections of existing CWMS users. The +collections must expose current user identity data, including names and email +addresses, without creating a second user identity store or changing CWMS +authorization groups into general-purpose application data. + +User lists also require an authenticated management interface. Reading a list is +useful to any authenticated CWMS user, while changing an office-owned list must +remain an office administration operation. + +## Decision + +CDA exposes office-scoped user lists backed by the CWMS database objects +`AT_USER_LISTS`, `AT_USER_LIST_MEMBERS`, and `AV_USER_LIST_MEMBERS`. +Membership references existing `AT_SEC_CWMS_USERS` rows. + +The REST resource is rooted at `/user/list`: + +- `GET /user/list?office=...` lists an office's lists. +- `POST /user/list` creates a list. +- `GET`, `PATCH`, and `DELETE /user/list/{user-list-id}?office=...` manage metadata. +- `GET` and `POST /user/list/{user-list-id}/members?office=...` read or add members. +- `DELETE /user/list/{user-list-id}/members/{user-id}?office=...` removes a member. + +Any authenticated principal with the `CWMS Users` role may read list metadata and +membership for any office. Mutations require `CWMS User Admins` membership for the +office named by the resource. CDA derives owner and audit user IDs from the +authenticated principal. + +The `USER_LISTS` Togglz feature controls route exposure. CDA registers the +documented handlers only when the feature is enabled. Requests are also guarded by +the minimum CWMS database schema version that contains the user-list objects, so a +deployment with an older schema receives an explicit unsupported response. + +The bundled CDA GUI provides the authenticated management surface. Existing public +CDA pages remain public, while the user-list route requires sign-in and renders +mutation controls only for offices the user may administer. + +## Alternatives Considered + +### Reuse CWMS security groups + +Rejected. Security groups carry authorization semantics, numeric group conventions, +and package behavior that do not apply to general-purpose contact lists. + +### Store independent user or email records + +Rejected. Duplicating identity data would drift from CWMS user profiles and require +new synchronization behavior. + +### Add PL/SQL CRUD packages + +Rejected for the initial implementation. CDA performs bind-variable SQL through its +DAO layer, keeping the resource contract portable and the database objects +relational. + +## Consequences + +- List IDs are stable references while member identity and email values remain + sourced from CWMS users. +- Office authorization is enforced by CDA rather than trusted to clients. +- Deployments must enable the feature only after installing the required schema. +- Future contact fields can be added to the membership view and DTO without + changing the core list-to-user relationship. + +## Implementation Status + +The database schema was introduced through CWMS database PR 160. CDA branch +`1733-user-lists` implements the resource handlers, schema and feature gating, +office-aware authorization, and management UI described here. diff --git a/docs/source/decisions/index.rst b/docs/source/decisions/index.rst index c24d77876b..a01accde0e 100644 --- a/docs/source/decisions/index.rst +++ b/docs/source/decisions/index.rst @@ -28,4 +28,5 @@ Some decisions may also be a proposal and marked appropriately. Handling Releases <./0009-code-changes-and-releases.rst> Vertical Datum Policy <./0010-vertical-datum.rst> JMS Queue Message Structure <./0011-queue-messages.rst> - Vertical Datum Storage <./0012-vertical-datum-storage.rst> \ No newline at end of file + Vertical Datum Storage <./0012-vertical-datum-storage.rst> + CDA User Lists <./0013-cda-user-lists.md> From e1be4417f977f426cafd438a8114f8ad5d67cd29 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Sat, 25 Jul 2026 17:05:16 -0500 Subject: [PATCH 07/13] Complete office-scoped CDA user lists --- compose_files/sql/users.sql | 5 + compose_files/togglz/features.properties | 1 + .../src/main/java/cwms/cda/ApiServlet.java | 17 +- .../AddUserListMemberController.java | 71 +++++ .../userlists/CreateUserListController.java | 64 ++++ .../userlists/DeleteUserListController.java | 60 ++++ .../userlists/UpdateUserListController.java | 68 +++++ .../UserListCandidatesController.java | 64 ++++ .../auth/userlists/UserListController.java | 42 +-- .../api/auth/userlists/UserListFeature.java | 7 + .../userlists/UserListMemberController.java | 32 +- .../userlists/UserListMembersController.java | 29 +- .../api/auth/userlists/UserListSupport.java | 74 ++++- .../auth/userlists/UserListsController.java | 49 ++- .../java/cwms/cda/data/dao/UserListDao.java | 123 ++++++-- .../cda/data/dto/auth/userlists/UserList.java | 10 +- .../dto/auth/userlists/UserListCandidate.java | 48 +++ .../auth/userlists/UserListCandidates.java | 22 ++ .../dto/auth/userlists/UserListInput.java | 11 + .../dto/auth/userlists/UserListMember.java | 7 +- .../auth/userlists/UserListMemberInput.java | 6 + .../api/users/UserListControllerTestIT.java | 279 ++++++++++++------ docs/source/decisions/0013-cda-user-lists.md | 30 +- 23 files changed, 891 insertions(+), 228 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/AddUserListMemberController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/CreateUserListController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/DeleteUserListController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UpdateUserListController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListCandidatesController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListCandidate.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListCandidates.java diff --git a/compose_files/sql/users.sql b/compose_files/sql/users.sql index 9eac95cc17..95febbf72e 100644 --- a/compose_files/sql/users.sql +++ b/compose_files/sql/users.sql @@ -19,6 +19,7 @@ begin cwms_sec.add_cwms_user('m5hectest', null, 'SWT'); cwms_sec.add_user_to_group('m5hectest', 'All Users', 'SWT'); cwms_sec.add_user_to_group('m5hectest', 'CWMS Users', 'SWT'); + cwms_sec.add_user_to_group('m5hectest', 'CWMS User Admins', 'SWT'); cwms_sec.add_user_to_group('m5hectest', 'TS ID Creator', 'SWT'); cwms_sec.add_cwms_user('q0hectest', null, 'SWT'); cwms_sec.add_user_to_group('q0hectest', 'All Users', 'SWT'); @@ -40,6 +41,7 @@ begin cwms_sec.add_cwms_user('m5hectest',NULL,'SWT'); cwms_sec.add_user_to_group('m5hectest','All Users', 'SWT'); cwms_sec.add_user_to_group('m5hectest','CWMS Users', 'SWT'); + cwms_sec.add_user_to_group('m5hectest','CWMS User Admins', 'SWT'); cwms_sec.add_user_to_group('m5hectest','TS ID Creator', 'SWT'); execute immediate 'grant execute on cwms_20.cwms_upass to web_user'; @@ -50,6 +52,9 @@ begin cwms_sec.add_user_to_group('m5testadmin','CWMS User Admins', 'LRL'); cwms_sec.add_user_to_group('m5testadmin','SHOW STACK TRACE', 'LRL'); + execute immediate 'grant insert, update, delete on cwms_20.at_user_lists to web_user'; + execute immediate 'grant insert, update, delete on cwms_20.at_user_list_members to web_user'; + end; / quit; diff --git a/compose_files/togglz/features.properties b/compose_files/togglz/features.properties index 663171a401..6262c035d0 100644 --- a/compose_files/togglz/features.properties +++ b/compose_files/togglz/features.properties @@ -1,2 +1,3 @@ USE_OBJECT_STORAGE_BLOBS=false INCLUDE_ERROR_STACK_TRACES=true +USER_LISTS=true diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index add97c5f18..4eeed4a797 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -105,10 +105,15 @@ import cwms.cda.api.auth.ApiKeyController; import cwms.cda.api.auth.users.UserProfileController; import cwms.cda.api.auth.users.UsersController; +import cwms.cda.api.auth.userlists.AddUserListMemberController; +import cwms.cda.api.auth.userlists.CreateUserListController; +import cwms.cda.api.auth.userlists.DeleteUserListController; +import cwms.cda.api.auth.userlists.UserListCandidatesController; import cwms.cda.api.auth.userlists.UserListController; import cwms.cda.api.auth.userlists.UserListMembersController; import cwms.cda.api.auth.userlists.UserListMemberController; import cwms.cda.api.auth.userlists.UserListsController; +import cwms.cda.api.auth.userlists.UpdateUserListController; import cwms.cda.features.CdaFeatures; import cwms.cda.api.auth.users.roles.AddRoleController; import cwms.cda.api.auth.users.roles.DeleteRolesController; @@ -686,20 +691,23 @@ private void addUserManagementHandlers() { } private void addUserListHandlers(RouteRole[] userRoles) { + String userListCandidatesPath = "/user/list-member-candidates"; String userListsPath = "/user/list"; String userListPath = "/user/list/{user-list-id}"; String userListMembersPath = "/user/list/{user-list-id}/members"; String userListMemberPath = "/user/list/{user-list-id}/members/{user-id}"; if (FeatureContext.getFeatureManager().isActive(CdaFeatures.USER_LISTS)) { + get(userListCandidatesPath, new UserListCandidatesController(metrics), userRoles); get(userListsPath, new UserListsController(metrics), userRoles); - post(userListsPath, new UserListsController(metrics), userRoles); + post(userListsPath, new CreateUserListController(metrics), userRoles); get(userListPath, new UserListController(metrics), userRoles); - patch(userListPath, new UserListController(metrics), userRoles); - delete(userListPath, new UserListController(metrics), userRoles); + patch(userListPath, new UpdateUserListController(metrics), userRoles); + delete(userListPath, new DeleteUserListController(metrics), userRoles); get(userListMembersPath, new UserListMembersController(metrics), userRoles); - post(userListMembersPath, new UserListMembersController(metrics), userRoles); + post(userListMembersPath, new AddUserListMemberController(metrics), userRoles); delete(userListMemberPath, new UserListMemberController(metrics), userRoles); } else { + get(userListCandidatesPath, this::userListsUnsupported, userRoles); get(userListsPath, this::userListsUnsupported, userRoles); post(userListsPath, this::userListsUnsupported, userRoles); get(userListPath, this::userListsUnsupported, userRoles); @@ -709,6 +717,7 @@ private void addUserListHandlers(RouteRole[] userRoles) { post(userListMembersPath, this::userListsUnsupported, userRoles); delete(userListMemberPath, this::userListsUnsupported, userRoles); } + cdaAccessManager.addCustomAuthorizer(userListCandidatesPath, ApiServlet::hasAnyRole); cdaAccessManager.addCustomAuthorizer(userListsPath, ApiServlet::hasAnyRole); cdaAccessManager.addCustomAuthorizer(userListPath, ApiServlet::hasAnyRole); cdaAccessManager.addCustomAuthorizer(userListMembersPath, ApiServlet::hasAnyRole); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/AddUserListMemberController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/AddUserListMemberController.java new file mode 100644 index 0000000000..fee57fb727 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/AddUserListMemberController.java @@ -0,0 +1,71 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.CREATE; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.STATUS_201; +import static cwms.cda.api.Controllers.USER_LIST_ID; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.data.dao.UserListDao; +import cwms.cda.data.dto.auth.userlists.UserListMember; +import cwms.cda.data.dto.auth.userlists.UserListMemberInput; +import cwms.cda.formatters.Formats; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.http.HttpCode; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiRequestBody; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.javalin.plugin.openapi.annotations.OpenApiSecurity; +import org.jooq.DSLContext; + +public final class AddUserListMemberController implements Handler { + private final MetricRegistry metrics; + + public AddUserListMemberController(MetricRegistry metrics) { + this.metrics = metrics; + } + + @OpenApi( + pathParams = @OpenApiParam(name = USER_LIST_ID, required = true, + description = "The office-scoped list identifier."), + queryParams = @OpenApiParam(name = OFFICE, required = true, + description = "The office that owns the list."), + requestBody = @OpenApiRequestBody(required = true, + content = @OpenApiContent(from = UserListMemberInput.class, type = Formats.JSON)), + responses = { + @OpenApiResponse(status = STATUS_201, + content = @OpenApiContent(from = UserListMember.class, type = Formats.JSON)), + @OpenApiResponse(status = "403", description = "Office administrator access required."), + @OpenApiResponse(status = "404", description = "List or CWMS user not found."), + @OpenApiResponse(status = "409", description = "User is already a member.") + }, + security = @OpenApiSecurity(name = "gets overridden allows lock icon."), + description = "Add an existing CWMS user to an office-scoped user list.", + method = HttpMethod.POST, + tags = UserListController.TAG + ) + @Override + public void handle(Context ctx) { + try (Timer.Context ignored = + Controllers.markAndTime(metrics, getClass().getName(), CREATE)) { + String office = UserListSupport.requiredOffice(ctx); + DSLContext dsl = UserListSupport.requireFeature(ctx, office); + if (dsl == null) { + return; + } + UserListDao dao = new UserListDao(dsl); + UserListSupport.requireOfficeAdmin(ctx, dao, office); + UserListMemberInput input = ctx.bodyAsClass(UserListMemberInput.class); + String userId = UserListSupport.validateUserId(input.getUserId()); + ctx.status(HttpCode.CREATED).json(dao.addMember( + office, UserListSupport.validateUserListId(ctx.pathParam(USER_LIST_ID)), userId, + UserListSupport.principal(ctx).getName())); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/CreateUserListController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/CreateUserListController.java new file mode 100644 index 0000000000..7ea21300a2 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/CreateUserListController.java @@ -0,0 +1,64 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.CREATE; +import static cwms.cda.api.Controllers.STATUS_201; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.data.dao.UserListDao; +import cwms.cda.data.dto.auth.userlists.UserList; +import cwms.cda.data.dto.auth.userlists.UserListInput; +import cwms.cda.formatters.Formats; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.http.HttpCode; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiRequestBody; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.javalin.plugin.openapi.annotations.OpenApiSecurity; +import org.jooq.DSLContext; + +public final class CreateUserListController implements Handler { + private final MetricRegistry metrics; + + public CreateUserListController(MetricRegistry metrics) { + this.metrics = metrics; + } + + @OpenApi( + requestBody = @OpenApiRequestBody(required = true, + content = @OpenApiContent(from = UserListInput.class, type = Formats.JSON)), + responses = { + @OpenApiResponse(status = STATUS_201, + content = @OpenApiContent(from = UserList.class, type = Formats.JSON)), + @OpenApiResponse(status = "403", description = "Office administrator access required."), + @OpenApiResponse(status = "409", description = "The office already has this list ID.") + }, + security = @OpenApiSecurity(name = "gets overridden allows lock icon."), + description = "Create an office-scoped user list owned by the authenticated user.", + method = HttpMethod.POST, + tags = UserListController.TAG + ) + @Override + public void handle(Context ctx) { + try (Timer.Context ignored = + Controllers.markAndTime(metrics, getClass().getName(), CREATE)) { + UserListInput input = ctx.bodyAsClass(UserListInput.class); + String office = UserListSupport.validateOffice(input.getOfficeId()); + String userListId = UserListSupport.validateUserListId(input.getUserListId()); + String description = UserListSupport.validateDescription(input.getDescription()); + DSLContext dsl = UserListSupport.requireFeature(ctx, office); + if (dsl == null) { + return; + } + UserListDao dao = new UserListDao(dsl); + UserListSupport.requireOfficeAdmin(ctx, dao, office); + UserList created = dao.createUserList(office, userListId, description, + UserListSupport.principal(ctx).getName()); + ctx.status(HttpCode.CREATED).json(created); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/DeleteUserListController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/DeleteUserListController.java new file mode 100644 index 0000000000..1cbbff3a95 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/DeleteUserListController.java @@ -0,0 +1,60 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.DELETE; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.STATUS_204; +import static cwms.cda.api.Controllers.USER_LIST_ID; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.data.dao.UserListDao; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.http.HttpCode; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.javalin.plugin.openapi.annotations.OpenApiSecurity; +import org.jooq.DSLContext; + +public final class DeleteUserListController implements Handler { + private final MetricRegistry metrics; + + public DeleteUserListController(MetricRegistry metrics) { + this.metrics = metrics; + } + + @OpenApi( + pathParams = @OpenApiParam(name = USER_LIST_ID, required = true, + description = "The office-scoped list identifier."), + queryParams = @OpenApiParam(name = OFFICE, required = true, + description = "The office that owns the list."), + responses = { + @OpenApiResponse(status = STATUS_204, description = "User list deleted."), + @OpenApiResponse(status = "403", description = "Office administrator access required."), + @OpenApiResponse(status = "404", description = "User list not found.") + }, + security = @OpenApiSecurity(name = "gets overridden allows lock icon."), + description = "Delete a user list and its membership rows.", + method = HttpMethod.DELETE, + tags = UserListController.TAG + ) + @Override + public void handle(Context ctx) { + try (Timer.Context ignored = + Controllers.markAndTime(metrics, getClass().getName(), DELETE)) { + String office = UserListSupport.requiredOffice(ctx); + DSLContext dsl = UserListSupport.requireFeature(ctx, office); + if (dsl == null) { + return; + } + UserListDao dao = new UserListDao(dsl); + UserListSupport.requireOfficeAdmin(ctx, dao, office); + dao.deleteUserList(office, + UserListSupport.validateUserListId(ctx.pathParam(USER_LIST_ID))); + ctx.status(HttpCode.NO_CONTENT); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UpdateUserListController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UpdateUserListController.java new file mode 100644 index 0000000000..ff7745c719 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UpdateUserListController.java @@ -0,0 +1,68 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.UPDATE; +import static cwms.cda.api.Controllers.USER_LIST_ID; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.data.dao.UserListDao; +import cwms.cda.data.dto.auth.userlists.UserList; +import cwms.cda.data.dto.auth.userlists.UserListInput; +import cwms.cda.formatters.Formats; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiRequestBody; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.javalin.plugin.openapi.annotations.OpenApiSecurity; +import org.jooq.DSLContext; + +public final class UpdateUserListController implements Handler { + private final MetricRegistry metrics; + + public UpdateUserListController(MetricRegistry metrics) { + this.metrics = metrics; + } + + @OpenApi( + pathParams = @OpenApiParam(name = USER_LIST_ID, required = true, + description = "The office-scoped list identifier."), + queryParams = @OpenApiParam(name = OFFICE, required = true, + description = "The office that owns the list."), + requestBody = @OpenApiRequestBody(required = true, + content = @OpenApiContent(from = UserListInput.class, type = Formats.JSON)), + responses = { + @OpenApiResponse(status = STATUS_200, + content = @OpenApiContent(from = UserList.class, type = Formats.JSON)), + @OpenApiResponse(status = "403", description = "Office administrator access required."), + @OpenApiResponse(status = "404", description = "User list not found.") + }, + security = @OpenApiSecurity(name = "gets overridden allows lock icon."), + description = "Update user-list metadata. Creator ownership is immutable.", + method = HttpMethod.PATCH, + tags = UserListController.TAG + ) + @Override + public void handle(Context ctx) { + try (Timer.Context ignored = + Controllers.markAndTime(metrics, getClass().getName(), UPDATE)) { + String office = UserListSupport.requiredOffice(ctx); + DSLContext dsl = UserListSupport.requireFeature(ctx, office); + if (dsl == null) { + return; + } + UserListDao dao = new UserListDao(dsl); + UserListSupport.requireOfficeAdmin(ctx, dao, office); + UserListInput input = ctx.bodyAsClass(UserListInput.class); + ctx.json(dao.updateUserList(office, + UserListSupport.validateUserListId(ctx.pathParam(USER_LIST_ID)), + UserListSupport.validateDescription(input.getDescription()))); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListCandidatesController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListCandidatesController.java new file mode 100644 index 0000000000..4a2bbcf077 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListCandidatesController.java @@ -0,0 +1,64 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.GET_ALL; +import static cwms.cda.api.Controllers.STATUS_200; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.data.dao.UserListDao; +import cwms.cda.data.dto.auth.userlists.UserListCandidates; +import cwms.cda.formatters.Formats; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.javalin.plugin.openapi.annotations.OpenApiSecurity; +import org.jooq.DSLContext; + +public final class UserListCandidatesController implements Handler { + private static final String SEARCH = "search"; + private static final String PAGE_SIZE = "page-size"; + private final MetricRegistry metrics; + + public UserListCandidatesController(MetricRegistry metrics) { + this.metrics = metrics; + } + + @OpenApi( + queryParams = { + @OpenApiParam(name = SEARCH, required = true, + description = "At least two characters from a user id, name, or email address."), + @OpenApiParam(name = PAGE_SIZE, type = Integer.class, + description = "Maximum candidates to return, from 1 through 50.") + }, + responses = { + @OpenApiResponse(status = STATUS_200, + content = @OpenApiContent(from = UserListCandidates.class, type = Formats.JSON)) + }, + security = @OpenApiSecurity(name = "gets overridden allows lock icon."), + description = "Search existing CWMS users for user-list membership.", + method = HttpMethod.GET, + tags = UserListController.TAG + ) + @Override + public void handle(Context ctx) { + try (Timer.Context ignored = + Controllers.markAndTime(metrics, getClass().getName(), GET_ALL)) { + String search = UserListSupport.validateCandidateSearch(ctx.queryParam(SEARCH)); + int pageSize = ctx.queryParamAsClass(PAGE_SIZE, Integer.class).getOrDefault(20); + if (pageSize < 1 || pageSize > 50) { + throw new IllegalArgumentException("page-size must be between 1 and 50"); + } + DSLContext dsl = UserListSupport.requireFeature(ctx); + if (dsl == null) { + return; + } + ctx.json(new UserListCandidates( + new UserListDao(dsl).searchCandidates(search, pageSize))); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java index 2491bc42d6..a63958b933 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java @@ -1,8 +1,6 @@ package cwms.cda.api.auth.userlists; import static cwms.cda.api.Controllers.GET_ONE; -import static cwms.cda.api.Controllers.DELETE; -import static cwms.cda.api.Controllers.UPDATE; import static cwms.cda.api.Controllers.OFFICE; import static cwms.cda.api.Controllers.STATUS_200; import static cwms.cda.api.Controllers.USER_LIST_ID; @@ -16,13 +14,11 @@ import cwms.cda.data.dao.UserListDao; import cwms.cda.data.dto.Office; import cwms.cda.data.dto.auth.userlists.UserList; -import cwms.cda.data.dto.auth.userlists.UserListInput; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; import io.javalin.core.util.Header; import io.javalin.http.Context; import io.javalin.http.Handler; -import io.javalin.http.HttpCode; import io.javalin.plugin.openapi.annotations.HttpMethod; import io.javalin.plugin.openapi.annotations.OpenApi; import io.javalin.plugin.openapi.annotations.OpenApiContent; @@ -69,13 +65,7 @@ private Timer.Context markAndTime(String subject) { ) @Override public void handle(Context ctx) { - if ("PATCH".equals(ctx.method())) { - update(ctx); - } else if ("DELETE".equals(ctx.method())) { - delete(ctx); - } else { - get(ctx); - } + get(ctx); } private void get(Context ctx) { @@ -89,7 +79,7 @@ private void get(Context ctx) { .check(Office::validOfficeNotNull, "Invalid office provided") .get(); - String userListId = ctx.pathParam(USER_LIST_ID); + String userListId = UserListSupport.validateUserListId(ctx.pathParam(USER_LIST_ID)); DSLContext dsl = getDslContext(ctx); if (!UserListFeature.requireSupported(ctx, dsl)) { return; @@ -108,32 +98,4 @@ private void get(Context ctx) { } } - private void update(Context ctx) { - try (final Timer.Context ignored = markAndTime(UPDATE)) { - String office = UserListSupport.requiredOffice(ctx); - DSLContext dsl = UserListSupport.requireFeature(ctx); - if (dsl == null) { - return; - } - UserListDao dao = new UserListDao(dsl); - UserListSupport.requireOfficeAdmin(ctx, dao, office); - UserListInput input = ctx.bodyAsClass(UserListInput.class); - ctx.json(dao.updateUserList(office, ctx.pathParam(USER_LIST_ID), - input.getDescription())); - } - } - - private void delete(Context ctx) { - try (final Timer.Context ignored = markAndTime(DELETE)) { - String office = UserListSupport.requiredOffice(ctx); - DSLContext dsl = UserListSupport.requireFeature(ctx); - if (dsl == null) { - return; - } - UserListDao dao = new UserListDao(dsl); - UserListSupport.requireOfficeAdmin(ctx, dao, office); - dao.deleteUserList(office, ctx.pathParam(USER_LIST_ID)); - ctx.status(HttpCode.NO_CONTENT); - } - } } diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListFeature.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListFeature.java index fd0b07b90a..61fd276387 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListFeature.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListFeature.java @@ -15,6 +15,13 @@ public final class UserListFeature { private UserListFeature() { } + /** + * Verifies that the connected CWMS schema supports user lists. + * + * @param ctx request context used to return an unsupported response + * @param dsl office-scoped database context + * @return true when user-list tables are available + */ public static boolean requireSupported(Context ctx, DSLContext dsl) { int version = Dao.versionAsInteger(Dao.getVersion(dsl)); if (version < V2026_07_16.numeric()) { diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMemberController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMemberController.java index 21ae2cd373..055a77008d 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMemberController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMemberController.java @@ -1,6 +1,8 @@ package cwms.cda.api.auth.userlists; import static cwms.cda.api.Controllers.DELETE; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.STATUS_204; import static cwms.cda.api.Controllers.USER_ID; import static cwms.cda.api.Controllers.USER_LIST_ID; @@ -11,6 +13,11 @@ import io.javalin.http.Context; import io.javalin.http.Handler; import io.javalin.http.HttpCode; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.javalin.plugin.openapi.annotations.OpenApiSecurity; import org.jooq.DSLContext; public final class UserListMemberController implements Handler { @@ -20,18 +27,39 @@ public UserListMemberController(MetricRegistry metrics) { this.metrics = metrics; } + @OpenApi( + pathParams = { + @OpenApiParam(name = USER_LIST_ID, required = true, + description = "The office-scoped list identifier."), + @OpenApiParam(name = USER_ID, required = true, + description = "The member user identifier.") + }, + queryParams = @OpenApiParam(name = OFFICE, required = true, + description = "The office that owns the list."), + responses = { + @OpenApiResponse(status = STATUS_204, description = "Member removed."), + @OpenApiResponse(status = "403", description = "Office administrator access required."), + @OpenApiResponse(status = "404", description = "List or member not found.") + }, + security = @OpenApiSecurity(name = "gets overridden allows lock icon."), + description = "Remove a member from an office-scoped user list.", + method = HttpMethod.DELETE, + tags = UserListController.TAG + ) @Override public void handle(Context ctx) { try (Timer.Context ignored = Controllers.markAndTime( metrics, getClass().getName(), DELETE)) { String office = UserListSupport.requiredOffice(ctx); - DSLContext dsl = UserListSupport.requireFeature(ctx); + DSLContext dsl = UserListSupport.requireFeature(ctx, office); if (dsl == null) { return; } UserListDao dao = new UserListDao(dsl); UserListSupport.requireOfficeAdmin(ctx, dao, office); - dao.removeMember(office, ctx.pathParam(USER_LIST_ID), ctx.pathParam(USER_ID)); + dao.removeMember(office, + UserListSupport.validateUserListId(ctx.pathParam(USER_LIST_ID)), + UserListSupport.validateUserId(ctx.pathParam(USER_ID))); ctx.status(HttpCode.NO_CONTENT); } } diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java index 9f9d92452a..49fde3dbe0 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java @@ -1,7 +1,6 @@ package cwms.cda.api.auth.userlists; import static cwms.cda.api.Controllers.GET_ONE; -import static cwms.cda.api.Controllers.CREATE; import static cwms.cda.api.Controllers.OFFICE; import static cwms.cda.api.Controllers.STATUS_200; import static cwms.cda.api.Controllers.USER_LIST_ID; @@ -14,13 +13,11 @@ import cwms.cda.data.dao.UserListDao; import cwms.cda.data.dto.Office; import cwms.cda.data.dto.auth.userlists.UserListMembers; -import cwms.cda.data.dto.auth.userlists.UserListMemberInput; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; import io.javalin.core.util.Header; import io.javalin.http.Context; import io.javalin.http.Handler; -import io.javalin.http.HttpCode; import io.javalin.plugin.openapi.annotations.HttpMethod; import io.javalin.plugin.openapi.annotations.OpenApi; import io.javalin.plugin.openapi.annotations.OpenApiContent; @@ -67,11 +64,7 @@ private Timer.Context markAndTime(String subject) { ) @Override public void handle(Context ctx) { - if ("POST".equals(ctx.method())) { - create(ctx); - } else { - get(ctx); - } + get(ctx); } private void get(Context ctx) { @@ -85,7 +78,7 @@ private void get(Context ctx) { .check(Office::validOfficeNotNull, "Invalid office provided") .get(); - String userListId = ctx.pathParam(USER_LIST_ID); + String userListId = UserListSupport.validateUserListId(ctx.pathParam(USER_LIST_ID)); DSLContext dsl = getDslContext(ctx); if (!UserListFeature.requireSupported(ctx, dsl)) { return; @@ -102,22 +95,4 @@ private void get(Context ctx) { } } - private void create(Context ctx) { - try (final Timer.Context ignored = markAndTime(CREATE)) { - String office = UserListSupport.requiredOffice(ctx); - DSLContext dsl = UserListSupport.requireFeature(ctx); - if (dsl == null) { - return; - } - UserListDao dao = new UserListDao(dsl); - UserListSupport.requireOfficeAdmin(ctx, dao, office); - UserListMemberInput input = ctx.bodyAsClass(UserListMemberInput.class); - if (input.getUserId() == null || input.getUserId().isBlank()) { - throw new IllegalArgumentException("user-id is required"); - } - ctx.status(HttpCode.CREATED).json(dao.addMember( - office, ctx.pathParam(USER_LIST_ID), input.getUserId(), - UserListSupport.principal(ctx).getName())); - } - } } diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListSupport.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListSupport.java index da635351c6..aef86f4d6d 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListSupport.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListSupport.java @@ -10,9 +10,17 @@ import cwms.cda.security.DataApiPrincipal; import io.javalin.http.Context; import io.javalin.http.ForbiddenResponse; +import java.util.Locale; +import java.util.regex.Pattern; import org.jooq.DSLContext; final class UserListSupport { + static final int USER_LIST_ID_MAX_LENGTH = 128; + static final int DESCRIPTION_MAX_LENGTH = 1024; + static final int USER_ID_MAX_LENGTH = 128; + private static final Pattern USER_LIST_ID_PATTERN = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + private UserListSupport() { } @@ -26,8 +34,23 @@ static String requiredOffice(Context ctx) { .get(); } + static String validateOffice(String office) { + if (office == null || office.isBlank()) { + throw new IllegalArgumentException("office-id is required"); + } + String normalized = office.strip().toUpperCase(Locale.ROOT); + if (!Office.validOfficeNotNull(normalized)) { + throw new IllegalArgumentException("Invalid office provided"); + } + return normalized; + } + static DSLContext requireFeature(Context ctx) { - DSLContext dsl = getDslContext(ctx); + return requireFeature(ctx, null); + } + + static DSLContext requireFeature(Context ctx, String office) { + DSLContext dsl = getDslContext(ctx, office); return UserListFeature.requireSupported(ctx, dsl) ? dsl : null; } @@ -35,6 +58,55 @@ static DataApiPrincipal principal(Context ctx) { return ctx.attribute(AuthDao.DATA_API_PRINCIPAL); } + static String validateUserListId(String userListId) { + if (userListId == null || userListId.isBlank()) { + throw new IllegalArgumentException("user-list-id is required"); + } + String normalized = userListId.strip().toUpperCase(Locale.ROOT); + if (!USER_LIST_ID_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("user-list-id must be 1-" + + USER_LIST_ID_MAX_LENGTH + + " characters and contain only letters, numbers, '.', '_' or '-'"); + } + return normalized; + } + + static String validateDescription(String description) { + if (description == null) { + return null; + } + String normalized = description.strip(); + if (normalized.length() > DESCRIPTION_MAX_LENGTH) { + throw new IllegalArgumentException("description must not exceed " + + DESCRIPTION_MAX_LENGTH + " characters"); + } + return normalized.isEmpty() ? null : normalized; + } + + static String validateUserId(String userId) { + if (userId == null || userId.isBlank()) { + throw new IllegalArgumentException("user-id is required"); + } + String normalized = userId.strip().toUpperCase(Locale.ROOT); + if (normalized.length() > USER_ID_MAX_LENGTH) { + throw new IllegalArgumentException("user-id must not exceed " + + USER_ID_MAX_LENGTH + " characters"); + } + return normalized; + } + + static String validateCandidateSearch(String search) { + if (search == null || search.isBlank() || search.strip().length() < 2) { + throw new IllegalArgumentException("search must contain at least 2 characters"); + } + String normalized = search.strip(); + if (normalized.length() > USER_ID_MAX_LENGTH) { + throw new IllegalArgumentException("search must not exceed " + + USER_ID_MAX_LENGTH + " characters"); + } + return normalized; + } + static void requireOfficeAdmin(Context ctx, UserListDao dao, String office) { DataApiPrincipal principal = principal(ctx); if (principal == null || !dao.isOfficeUserAdmin(principal.getName(), office)) { diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListsController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListsController.java index d270fd212e..689e3d93a8 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListsController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListsController.java @@ -1,18 +1,23 @@ package cwms.cda.api.auth.userlists; -import static cwms.cda.api.Controllers.CREATE; import static cwms.cda.api.Controllers.GET_ALL; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.STATUS_200; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; import cwms.cda.api.Controllers; import cwms.cda.data.dao.UserListDao; -import cwms.cda.data.dto.auth.userlists.UserList; -import cwms.cda.data.dto.auth.userlists.UserListInput; import cwms.cda.data.dto.auth.userlists.UserLists; +import cwms.cda.formatters.Formats; import io.javalin.http.Context; import io.javalin.http.Handler; -import io.javalin.http.HttpCode; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.javalin.plugin.openapi.annotations.OpenApiSecurity; import org.jooq.DSLContext; public final class UserListsController implements Handler { @@ -22,13 +27,19 @@ public UserListsController(MetricRegistry metrics) { this.metrics = metrics; } + @OpenApi( + queryParams = @OpenApiParam(name = OFFICE, required = true, + description = "The office whose user lists should be returned."), + responses = @OpenApiResponse(status = STATUS_200, + content = @OpenApiContent(from = UserLists.class, type = Formats.JSON)), + security = @OpenApiSecurity(name = "gets overridden allows lock icon."), + description = "List office-scoped reusable user lists.", + method = HttpMethod.GET, + tags = UserListController.TAG + ) @Override public void handle(Context ctx) { - if ("GET".equals(ctx.method())) { - getAll(ctx); - } else { - create(ctx); - } + getAll(ctx); } private void getAll(Context ctx) { @@ -41,24 +52,4 @@ private void getAll(Context ctx) { ctx.json(new UserLists(new UserListDao(dsl).getUserLists(office))); } } - - private void create(Context ctx) { - try (Timer.Context ignored = Controllers.markAndTime(metrics, getClass().getName(), CREATE)) { - UserListInput input = ctx.bodyAsClass(UserListInput.class); - String office = input.getOfficeId(); - if (office == null || office.isBlank() || input.getUserListId() == null - || input.getUserListId().isBlank()) { - throw new IllegalArgumentException("office-id and user-list-id are required"); - } - DSLContext dsl = UserListSupport.requireFeature(ctx); - if (dsl == null) { - return; - } - UserListDao dao = new UserListDao(dsl); - UserListSupport.requireOfficeAdmin(ctx, dao, office); - UserList created = dao.createUserList(office, input.getUserListId(), - input.getDescription(), UserListSupport.principal(ctx).getName()); - ctx.status(HttpCode.CREATED).json(created); - } - } } diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java index fd31378344..e6f55120e1 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java @@ -8,31 +8,32 @@ import cwms.cda.api.errors.NotFoundException; import cwms.cda.data.dto.auth.userlists.UserList; +import cwms.cda.data.dto.auth.userlists.UserListCandidate; import cwms.cda.data.dto.auth.userlists.UserListMember; import cwms.cda.data.dto.auth.userlists.UserListMembers; +import io.javalin.http.ConflictResponse; import java.sql.Timestamp; import java.util.List; +import java.util.Locale; import java.util.Optional; import org.jooq.Condition; import org.jooq.DSLContext; import org.jooq.Field; import org.jooq.Table; +import org.jooq.impl.DSL; -public final class UserListDao extends Dao { +public final class UserListDao { + private final DSLContext dsl; private final Table avUserListMembers = table(name("CWMS_20", "AV_USER_LIST_MEMBERS")).as("ulm"); private final Table atUserLists = table(name("CWMS_20", "AT_USER_LISTS")).as("ul"); private final Table cwmsOffice = table(name("CWMS_20", "CWMS_OFFICE")).as("co"); private final Table atSecUsers = table(name("CWMS_20", "AT_SEC_USERS")).as("su"); private final Table atSecUserGroups = table(name("CWMS_20", "AT_SEC_USER_GROUPS")).as("sg"); + private final Table avCwmsUser = table(name("CWMS_20", "AV_CWMS_USER")).as("acu"); public UserListDao(DSLContext dsl) { - super(dsl); - } - - @Override - public Optional getByUniqueName(String uniqueName, String office) { - return Optional.empty(); + this.dsl = dsl; } public Optional getUserList(String officeId, String userListId) { @@ -89,21 +90,26 @@ public List getUserLists(String officeId) { public UserList createUserList(String officeId, String userListId, String description, String owner) { + if (userListExists(officeId, userListId)) { + throw new ConflictResponse("User list already exists: " + officeId + "/" + userListId); + } + return dsl.transactionResult(configuration -> { + UserListDao transactionDao = new UserListDao(DSL.using(configuration)); + return transactionDao.insertUserList(officeId, userListId, description, owner); + }); + } + + private UserList insertUserList(String officeId, String userListId, String description, + String owner) { Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); Field listDescription = field(name(atUserLists.getName(), "USER_LIST_DESC"), String.class); Field listOwner = field(name(atUserLists.getName(), "OWNED_BY_USERID"), String.class); - Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); - Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); - Long resolvedOfficeCode = dsl.select(officeCode) - .from(cwmsOffice) - .where(ignoreCaseEq(officeName, officeId)) - .fetchOptional(officeCode) - .orElseThrow(() -> new NotFoundException("Office not found: " + officeId)); + Long resolvedOfficeCode = resolveOfficeCode(officeId); dsl.insertInto(atUserLists) .columns(listOfficeCode, listUserListId, listDescription, listOwner) - .values(resolvedOfficeCode, userListId.toUpperCase(), description, owner.toUpperCase()) + .values(resolvedOfficeCode, normalizeId(userListId), description, normalizeId(owner)) .execute(); return getUserList(officeId, userListId) .orElseThrow(() -> new NotFoundException("Created user list was not found")); @@ -113,10 +119,7 @@ public UserList updateUserList(String officeId, String userListId, String descri Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); Field listDescription = field(name(atUserLists.getName(), "USER_LIST_DESC"), String.class); - Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); - Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); - Long resolvedOfficeCode = dsl.select(officeCode).from(cwmsOffice) - .where(ignoreCaseEq(officeName, officeId)).fetchOne(officeCode); + Long resolvedOfficeCode = resolveOfficeCode(officeId); int changed = dsl.update(atUserLists) .set(listDescription, description) .where(listOfficeCode.eq(resolvedOfficeCode)) @@ -129,16 +132,24 @@ public UserList updateUserList(String officeId, String userListId, String descri } public void deleteUserList(String officeId, String userListId) { - Field memberListId = field(name("AT_USER_LIST_MEMBERS", "USER_LIST_ID"), String.class); - Table members = table(name("CWMS_20", "AT_USER_LIST_MEMBERS")); - dsl.deleteFrom(members).where(ignoreCaseEq(memberListId, userListId)).execute(); + if (!userListExists(officeId, userListId)) { + throw new NotFoundException("User list not found: " + officeId + "/" + userListId); + } + dsl.transaction(configuration -> new UserListDao(DSL.using(configuration)) + .deleteUserListRows(officeId, userListId)); + } + private void deleteUserListRows(String officeId, String userListId) { + Table members = table(name("CWMS_20", "AT_USER_LIST_MEMBERS")).as("ulm_delete"); + Field memberOfficeCode = field(name(members.getName(), "DB_OFFICE_CODE"), Long.class); + Field memberListId = field(name(members.getName(), "USER_LIST_ID"), String.class); Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); - Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); - Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); - Long resolvedOfficeCode = dsl.select(officeCode).from(cwmsOffice) - .where(ignoreCaseEq(officeName, officeId)).fetchOne(officeCode); + Long resolvedOfficeCode = resolveOfficeCode(officeId); + dsl.deleteFrom(members) + .where(memberOfficeCode.eq(resolvedOfficeCode)) + .and(ignoreCaseEq(memberListId, userListId)) + .execute(); int deleted = dsl.deleteFrom(atUserLists) .where(listOfficeCode.eq(resolvedOfficeCode)) .and(ignoreCaseEq(listUserListId, userListId)) @@ -195,6 +206,30 @@ public UserListMembers getMembers(String officeId, String userListId) { return new UserListMembers(members); } + public List searchCandidates(String search, int pageSize) { + Field userId = field(name(avCwmsUser.getName(), "USER_ID"), String.class); + Field fullName = field(name(avCwmsUser.getName(), "FULL_NAME"), String.class); + Field email = field(name(avCwmsUser.getName(), "EMAIL"), String.class); + Field officeId = field(name(avCwmsUser.getName(), "OFFICE_ID"), String.class); + String contains = "%" + search.toUpperCase(Locale.ROOT) + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + "%"; + Condition matches = upper(userId).like(contains, '\\') + .or(upper(fullName).like(contains, '\\')) + .or(upper(email).like(contains, '\\')); + return dsl.select(userId, fullName, email, officeId) + .from(avCwmsUser) + .where(matches) + .orderBy(fullName.asc().nullsLast(), userId) + .limit(pageSize) + .fetch(record -> new UserListCandidate( + record.get(userId), + record.get(fullName), + record.get(email), + record.get(officeId))); + } + public UserListMember addMember(String officeId, String userListId, String userId, String addedBy) { if (!userListExists(officeId, userListId)) { @@ -209,12 +244,23 @@ public UserListMember addMember(String officeId, String userListId, String userI } Table members = table(name("CWMS_20", "AT_USER_LIST_MEMBERS")).as("ulm_write"); + Field memberOfficeCode = field(name(members.getName(), "DB_OFFICE_CODE"), Long.class); Field memberListId = field(name(members.getName(), "USER_LIST_ID"), String.class); Field memberUserId = field(name(members.getName(), "USERID"), String.class); Field memberAddedBy = field(name(members.getName(), "ADDED_BY_USERID"), String.class); + Long resolvedOfficeCode = resolveOfficeCode(officeId); + boolean memberExists = dsl.fetchExists(selectOne().from(members) + .where(memberOfficeCode.eq(resolvedOfficeCode)) + .and(ignoreCaseEq(memberListId, userListId)) + .and(ignoreCaseEq(memberUserId, userId))); + if (memberExists) { + throw new ConflictResponse("User is already a member of " + + officeId + "/" + userListId + ": " + userId); + } dsl.insertInto(members) - .columns(memberListId, memberUserId, memberAddedBy) - .values(userListId.toUpperCase(), userId.toUpperCase(), addedBy.toUpperCase()) + .columns(memberOfficeCode, memberListId, memberUserId, memberAddedBy) + .values(resolvedOfficeCode, normalizeId(userListId), normalizeId(userId), + normalizeId(addedBy)) .execute(); return getMembers(officeId, userListId).getMembers().stream() .filter(member -> member.getUserId().equalsIgnoreCase(userId)) @@ -227,10 +273,13 @@ public void removeMember(String officeId, String userListId, String userId) { throw new NotFoundException("User list not found: " + officeId + "/" + userListId); } Table members = table(name("CWMS_20", "AT_USER_LIST_MEMBERS")).as("ulm_write"); + Field memberOfficeCode = field(name(members.getName(), "DB_OFFICE_CODE"), Long.class); Field memberListId = field(name(members.getName(), "USER_LIST_ID"), String.class); Field memberUserId = field(name(members.getName(), "USERID"), String.class); + Long resolvedOfficeCode = resolveOfficeCode(officeId); int deleted = dsl.deleteFrom(members) - .where(ignoreCaseEq(memberListId, userListId)) + .where(memberOfficeCode.eq(resolvedOfficeCode)) + .and(ignoreCaseEq(memberListId, userListId)) .and(ignoreCaseEq(memberUserId, userId)) .execute(); if (deleted == 0) { @@ -254,6 +303,20 @@ private boolean userListExists(String officeId, String userListId) { } private static Condition ignoreCaseEq(Field field, String value) { - return upper(field).eq(value.toUpperCase()); + return upper(field).eq(normalizeId(value)); + } + + private Long resolveOfficeCode(String officeId) { + Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); + Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); + return dsl.select(officeCode) + .from(cwmsOffice) + .where(ignoreCaseEq(officeName, officeId)) + .fetchOptional(officeCode) + .orElseThrow(() -> new NotFoundException("Office not found: " + officeId)); + } + + private static String normalizeId(String value) { + return value.toUpperCase(Locale.ROOT); } } diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java index 177522a571..1dd18563cd 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java @@ -20,13 +20,14 @@ public final class UserList extends CwmsDTOBase { private final String officeId; @JsonProperty(required = true) - @Schema(description = "The identifier of the user list.") + @Schema(description = "The office-scoped identifier of the user list.", maxLength = 128) private final String userListId; - @Schema(description = "The user list description.") + @Schema(description = "The user list description.", maxLength = 1024) private final String description; - @Schema(description = "The user id of the list owner.") + @Schema(description = "The immutable user id of the authenticated principal that created " + + "the list.") private final String ownedByUserId; @JsonProperty(required = true) @@ -36,6 +37,9 @@ public final class UserList extends CwmsDTOBase { @Schema(description = "The time the user list was last updated.") private final Instant updatedAt; + /** + * Creates an office-scoped user-list representation. + */ public UserList(String officeId, String userListId, String description, String ownedByUserId, Instant createdAt, Instant updatedAt) { this.officeId = officeId; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListCandidate.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListCandidate.java new file mode 100644 index 0000000000..ceea7f44ca --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListCandidate.java @@ -0,0 +1,48 @@ +package cwms.cda.data.dto.auth.userlists; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +public final class UserListCandidate { + @JsonProperty(required = true) + @Schema(description = "The existing CWMS user identifier.") + private final String userId; + + @Schema(description = "The user's display name.") + private final String fullName; + + @Schema(description = "The user's current email address.") + private final String email; + + @Schema(description = "The user's current CWMS office identifier.") + private final String officeId; + + /** + * Creates a user-list membership candidate. + */ + public UserListCandidate(String userId, String fullName, String email, String officeId) { + this.userId = userId; + this.fullName = fullName; + this.email = email; + this.officeId = officeId; + } + + public String getUserId() { + return userId; + } + + public String getFullName() { + return fullName; + } + + public String getEmail() { + return email; + } + + public String getOfficeId() { + return officeId; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListCandidates.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListCandidates.java new file mode 100644 index 0000000000..9969b306fe --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListCandidates.java @@ -0,0 +1,22 @@ +package cwms.cda.data.dto.auth.userlists; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +public final class UserListCandidates { + @JsonProperty(required = true) + @Schema(description = "Existing CWMS users matching the supplied search text.") + private final List candidates; + + public UserListCandidates(List candidates) { + this.candidates = candidates; + } + + public List getCandidates() { + return candidates; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListInput.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListInput.java index 570c7cda5d..8d64cf1f46 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListInput.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListInput.java @@ -1,8 +1,19 @@ package cwms.cda.data.dto.auth.userlists; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) public final class UserListInput { + @Schema(description = "The office that owns the list.") private String officeId; + + @Schema(description = "The office-scoped list identifier.", maxLength = 128, + pattern = "[A-Za-z0-9][A-Za-z0-9._-]{0,127}") private String userListId; + + @Schema(description = "Optional list description.", maxLength = 1024) private String description; public UserListInput() { diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java index f0e2da8675..219c3ad750 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java @@ -19,11 +19,11 @@ public final class UserListMember extends CwmsDTOBase { private final String officeId; @JsonProperty(required = true) - @Schema(description = "The identifier of the user list.") + @Schema(description = "The office-scoped identifier of the user list.", maxLength = 128) private final String userListId; @JsonProperty(required = true) - @Schema(description = "The user identifier for the member.") + @Schema(description = "The user identifier for the member.", maxLength = 128) private final String userId; @Schema(description = "The user's display name.") @@ -32,6 +32,9 @@ public final class UserListMember extends CwmsDTOBase { @Schema(description = "The user's email address.") private final String email; + /** + * Creates an office-scoped user-list member representation. + */ public UserListMember(String officeId, String userListId, String userId, String fullName, String email) { this.officeId = officeId; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMemberInput.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMemberInput.java index 22c71b4eac..7416d5feb7 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMemberInput.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMemberInput.java @@ -1,6 +1,12 @@ package cwms.cda.data.dto.auth.userlists; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) public final class UserListMemberInput { + @Schema(description = "An existing CWMS user identifier.", maxLength = 128) private String userId; public UserListMemberInput() { diff --git a/cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java index 00839a2694..dad131e749 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java @@ -2,19 +2,24 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.is; import cwms.cda.api.DataApiTestIT; +import cwms.cda.features.CdaFeatureManagerProvider; +import cwms.cda.features.CdaFeatures; import fixtures.CwmsDataApiSetupCallback; import fixtures.KeyCloakExtension; import fixtures.TestAccounts; import fixtures.users.UserSpecSource; import fixtures.users.annotation.AuthType; import io.javalin.http.HttpCode; -import io.restassured.filter.log.LogDetail; +import io.restassured.http.ContentType; import io.restassured.specification.RequestSpecification; +import java.io.File; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.util.Locale; import mil.army.usace.hec.test.database.CwmsDatabaseContainer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -23,58 +28,69 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ArgumentsSource; +import org.togglz.core.context.FeatureContext; @Tag("integration") @ExtendWith(KeyCloakExtension.class) public final class UserListControllerTestIT extends DataApiTestIT { private static final String OFFICE = "SPK"; + private static final String OTHER_OFFICE = "SWT"; private static final String USER_LIST_ID = "USER_LIST_CONTROLLER_TEST"; + private static final String SECONDARY_LIST_ID = USER_LIST_ID + "_SECONDARY"; private static final String USER_LIST_DESC = "Integration test user list"; + private static final String OWNER = "L2HECTEST_VT"; + + static { + System.setProperty(CdaFeatureManagerProvider.PROPERTIES_FILE, + new File(CdaFeatureManagerProvider.DEFAULT_PROPERTIES_FILE).getAbsolutePath()); + FeatureContext.getFeatureManager().enable(CdaFeatures.USER_LISTS); + } @BeforeAll static void ensureUserListSchema() throws SQLException { CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); db.connection(c -> { try { - executeIgnoreObjectExists(c, + executeIgnoreObjectMissing(c, "DROP VIEW AV_USER_LIST_MEMBERS"); + executeIgnoreObjectMissing(c, + "DROP TABLE AT_USER_LIST_MEMBERS CASCADE CONSTRAINTS"); + executeIgnoreObjectMissing(c, "DROP TABLE AT_USER_LISTS CASCADE CONSTRAINTS"); + execute(c, "CREATE TABLE AT_USER_LISTS (" + "DB_OFFICE_CODE NUMBER NOT NULL, " + "USER_LIST_ID VARCHAR2(128) NOT NULL, " + "USER_LIST_DESC VARCHAR2(1024), " - + "OWNED_BY_USERID VARCHAR2(128), " + + "OWNED_BY_USERID VARCHAR2(128) NOT NULL, " + "CREATED_AT TIMESTAMP DEFAULT current_timestamp NOT NULL, " - + "UPDATED_AT TIMESTAMP)"); - executeIgnoreObjectExists(c, - "CREATE UNIQUE INDEX AT_USER_LISTS_PK ON AT_USER_LISTS (USER_LIST_ID)"); - executeIgnoreObjectExists(c, - "ALTER TABLE AT_USER_LISTS ADD CONSTRAINT AT_USER_LISTS_PK " - + "PRIMARY KEY (USER_LIST_ID) USING INDEX AT_USER_LISTS_PK"); - executeIgnoreObjectExists(c, - "ALTER TABLE AT_USER_LISTS ADD CONSTRAINT AT_USER_LISTS_FK1 " - + "FOREIGN KEY (DB_OFFICE_CODE) REFERENCES CWMS_OFFICE (OFFICE_CODE)"); - executeIgnoreObjectExists(c, + + "UPDATED_AT TIMESTAMP, " + + "CONSTRAINT AT_USER_LISTS_PK PRIMARY KEY " + + "(DB_OFFICE_CODE, USER_LIST_ID), " + + "CONSTRAINT AT_USER_LISTS_FK1 FOREIGN KEY (DB_OFFICE_CODE) " + + "REFERENCES CWMS_OFFICE (OFFICE_CODE), " + + "CONSTRAINT AT_USER_LISTS_FK2 FOREIGN KEY (OWNED_BY_USERID) " + + "REFERENCES AT_SEC_CWMS_USERS (USERID))"); + execute(c, "CREATE OR REPLACE TRIGGER AT_USER_LISTS_TRIG " + "BEFORE INSERT OR UPDATE ON AT_USER_LISTS " + "REFERENCING NEW AS new OLD AS old FOR EACH ROW BEGIN " + ":new.user_list_id := UPPER(:new.user_list_id); " + ":new.owned_by_userid := UPPER(:new.owned_by_userid); " + ":new.updated_at := current_timestamp; END;"); - executeIgnoreObjectExists(c, + execute(c, "CREATE TABLE AT_USER_LIST_MEMBERS (" + + "DB_OFFICE_CODE NUMBER NOT NULL, " + "USER_LIST_ID VARCHAR2(128) NOT NULL, " + "USERID VARCHAR2(128) NOT NULL, " + "ADD_DATE TIMESTAMP DEFAULT current_timestamp NOT NULL, " - + "ADDED_BY_USERID VARCHAR2(128))"); - executeIgnoreObjectExists(c, - "CREATE UNIQUE INDEX AT_USER_LIST_MEMBERS_PK " - + "ON AT_USER_LIST_MEMBERS (USER_LIST_ID, USERID)"); - executeIgnoreObjectExists(c, - "ALTER TABLE AT_USER_LIST_MEMBERS ADD CONSTRAINT AT_USER_LIST_MEMBERS_PK " - + "PRIMARY KEY (USER_LIST_ID, USERID) USING INDEX AT_USER_LIST_MEMBERS_PK"); - executeIgnoreObjectExists(c, - "ALTER TABLE AT_USER_LIST_MEMBERS ADD CONSTRAINT AT_USER_LIST_MEMBERS_FK1 " - + "FOREIGN KEY (USER_LIST_ID) REFERENCES AT_USER_LISTS (USER_LIST_ID)"); - executeIgnoreObjectExists(c, + + "ADDED_BY_USERID VARCHAR2(128), " + + "CONSTRAINT AT_USER_LIST_MEMBERS_PK PRIMARY KEY " + + "(DB_OFFICE_CODE, USER_LIST_ID, USERID), " + + "CONSTRAINT AT_USER_LIST_MEMBERS_FK1 " + + "FOREIGN KEY (DB_OFFICE_CODE, USER_LIST_ID) " + + "REFERENCES AT_USER_LISTS (DB_OFFICE_CODE, USER_LIST_ID), " + + "CONSTRAINT AT_USER_LIST_MEMBERS_FK2 FOREIGN KEY (USERID) " + + "REFERENCES AT_SEC_CWMS_USERS (USERID))"); + execute(c, "CREATE OR REPLACE VIEW AV_USER_LIST_MEMBERS (OFFICE_ID, DB_OFFICE_CODE, " + "USER_LIST_ID, USER_LIST_DESC, OWNED_BY_USERID, USER_ID, FULL_NAME, " + "EMAIL, OFFICE_SYMBOL, MEMBER_OFFICE_ID, ADD_DATE, ADDED_BY_USERID) AS " @@ -83,11 +99,16 @@ static void ensureUserListSchema() throws SQLException { + "u.office_id AS member_office_id, m.add_date, m.added_by_userid " + "FROM at_user_lists l " + "JOIN cwms_office o ON o.office_code = l.db_office_code " - + "JOIN at_user_list_members m ON m.user_list_id = l.user_list_id " + + "JOIN at_user_list_members m " + + "ON m.db_office_code = l.db_office_code " + + "AND m.user_list_id = l.user_list_id " + "JOIN av_cwms_user u ON u.user_id = m.userid"); - executeIgnoreInsufficientPrivilege(c, "GRANT SELECT ON AT_USER_LISTS TO CWMS_USER"); - executeIgnoreInsufficientPrivilege(c, "GRANT SELECT ON AT_USER_LIST_MEMBERS TO CWMS_USER"); - executeIgnoreInsufficientPrivilege(c, "GRANT SELECT ON AV_USER_LIST_MEMBERS TO CWMS_USER"); + executeIgnoreInsufficientPrivilege(c, + "GRANT SELECT, INSERT, UPDATE, DELETE ON AT_USER_LISTS TO CWMS_USER"); + executeIgnoreInsufficientPrivilege(c, + "GRANT SELECT, INSERT, UPDATE, DELETE ON AT_USER_LIST_MEMBERS TO CWMS_USER"); + executeIgnoreInsufficientPrivilege(c, + "GRANT SELECT ON AV_USER_LIST_MEMBERS TO CWMS_USER"); } catch (SQLException ex) { throw new RuntimeException(ex); } @@ -96,87 +117,177 @@ static void ensureUserListSchema() throws SQLException { @BeforeEach void createUserList() throws SQLException { + cleanUserLists(); + insertUserList(OFFICE, USER_LIST_ID, OWNER); + } + + @AfterEach + void deleteUserLists() throws SQLException { + cleanUserLists(); + } + + @ParameterizedTest + @ArgumentsSource(UserSpecSource.class) + @AuthType(user = TestAccounts.KeyUser.SPK_NORMAL2) + void test_full_user_list_crud(String authType, TestAccounts.KeyUser user, + RequestSpecification authSpec) { + given().spec(authSpec).queryParam("office", OFFICE) + .when().get("/user/list/{user-list-id}", USER_LIST_ID) + .then().statusCode(HttpCode.OK.getStatus()) + .body("office-id", equalTo(OFFICE)) + .body("user-list-id", equalTo(USER_LIST_ID)) + .body("description", equalTo(USER_LIST_DESC)) + .body("owned-by-user-id", equalTo(OWNER)); + + given().spec(authSpec).queryParam("office", OFFICE) + .when().get("/user/list") + .then().statusCode(HttpCode.OK.getStatus()) + .body("user-lists.user-list-id", hasItem(USER_LIST_ID)); + + given().spec(authSpec).contentType(ContentType.JSON) + .body("{\"office-id\":\"SPK\",\"user-list-id\":\"" + + SECONDARY_LIST_ID + "\",\"description\":\"Created through CDA\"}") + .when().post("/user/list") + .then().statusCode(HttpCode.CREATED.getStatus()) + .body("owned-by-user-id", + equalTo(user.getName().toUpperCase(Locale.ROOT))); + + given().spec(authSpec).contentType(ContentType.JSON).queryParam("office", OFFICE) + .body("{\"description\":\"Updated through CDA\"}") + .when().patch("/user/list/{user-list-id}", SECONDARY_LIST_ID) + .then().statusCode(HttpCode.OK.getStatus()) + .body("description", equalTo("Updated through CDA")); + + given().spec(authSpec).queryParam("office", OFFICE) + .when().delete("/user/list/{user-list-id}", SECONDARY_LIST_ID) + .then().statusCode(HttpCode.NO_CONTENT.getStatus()); + } + + @ParameterizedTest + @ArgumentsSource(UserSpecSource.class) + @AuthType(user = TestAccounts.KeyUser.SPK_NORMAL2) + void test_membership_crud(String authType, TestAccounts.KeyUser user, + RequestSpecification authSpec) { + given().spec(authSpec).queryParam("search", "l2hec") + .when().get("/user/list-member-candidates") + .then().statusCode(HttpCode.OK.getStatus()) + .body("candidates.user-id", hasItem("L2HECTEST")); + + given().spec(authSpec).contentType(ContentType.JSON).queryParam("office", OFFICE) + .body("{\"user-id\":\"L2HECTEST\"}") + .when().post("/user/list/{user-list-id}/members", USER_LIST_ID) + .then().statusCode(HttpCode.CREATED.getStatus()) + .body("user-id", equalTo("L2HECTEST")); + + given().spec(authSpec).queryParam("office", OFFICE) + .when().get("/user/list/{user-list-id}/members", USER_LIST_ID) + .then().statusCode(HttpCode.OK.getStatus()) + .body("members.user-id", hasItem("L2HECTEST")); + + given().spec(authSpec).queryParam("office", OFFICE) + .when().delete("/user/list/{user-list-id}/members/{user-id}", + USER_LIST_ID, "L2HECTEST") + .then().statusCode(HttpCode.NO_CONTENT.getStatus()); + } + + @ParameterizedTest + @ArgumentsSource(UserSpecSource.class) + @AuthType(user = TestAccounts.KeyUser.SPK_NORMAL2) + void test_duplicate_and_validation_errors(String authType, TestAccounts.KeyUser user, + RequestSpecification authSpec) { + given().spec(authSpec).contentType(ContentType.JSON) + .body("{\"office-id\":\"SPK\",\"user-list-id\":\"" + + USER_LIST_ID + "\"}") + .when().post("/user/list") + .then().statusCode(HttpCode.CONFLICT.getStatus()); + + given().spec(authSpec).contentType(ContentType.JSON) + .body("{\"office-id\":\"SPK\",\"user-list-id\":\"bad list id\"}") + .when().post("/user/list") + .then().statusCode(HttpCode.BAD_REQUEST.getStatus()); + } + + @ParameterizedTest + @ArgumentsSource(UserSpecSource.class) + @AuthType(user = TestAccounts.KeyUser.SPK_NORMAL) + void test_non_admin_cannot_mutate(String authType, TestAccounts.KeyUser user, + RequestSpecification authSpec) { + given().spec(authSpec).contentType(ContentType.JSON) + .body("{\"office-id\":\"SPK\",\"user-list-id\":\"" + + SECONDARY_LIST_ID + "\"}") + .when().post("/user/list") + .then().statusCode(HttpCode.FORBIDDEN.getStatus()); + } + + @ParameterizedTest + @ArgumentsSource(UserSpecSource.class) + @AuthType(user = TestAccounts.KeyUser.SPK_NORMAL2) + void test_same_list_id_is_available_in_multiple_offices(String authType, + TestAccounts.KeyUser user, RequestSpecification authSpec) throws SQLException { + insertUserList(OTHER_OFFICE, USER_LIST_ID, "M5HECTEST"); + + given().spec(authSpec).queryParam("office", OTHER_OFFICE) + .when().get("/user/list/{user-list-id}", USER_LIST_ID) + .then().statusCode(HttpCode.OK.getStatus()) + .body("office-id", equalTo(OTHER_OFFICE)) + .body("user-list-id", equalTo(USER_LIST_ID)); + } + + private void insertUserList(String office, String listId, String owner) throws SQLException { CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); db.connection(c -> { - try { - try (PreparedStatement deleteMembers = c.prepareStatement( - "DELETE FROM AT_USER_LIST_MEMBERS WHERE USER_LIST_ID = ?"); - PreparedStatement deleteList = c.prepareStatement( - "DELETE FROM AT_USER_LISTS WHERE USER_LIST_ID = ?"); - PreparedStatement insertList = c.prepareStatement( - "INSERT INTO AT_USER_LISTS (DB_OFFICE_CODE, USER_LIST_ID, USER_LIST_DESC) " - + "SELECT OFFICE_CODE, ?, ? FROM CWMS_OFFICE WHERE OFFICE_ID = ?")) { - deleteMembers.setString(1, USER_LIST_ID); - deleteMembers.executeUpdate(); - deleteList.setString(1, USER_LIST_ID); - deleteList.executeUpdate(); - insertList.setString(1, USER_LIST_ID); - insertList.setString(2, USER_LIST_DESC); - insertList.setString(3, OFFICE); - insertList.executeUpdate(); - } + try (PreparedStatement insertList = c.prepareStatement( + "INSERT INTO AT_USER_LISTS " + + "(DB_OFFICE_CODE, USER_LIST_ID, USER_LIST_DESC, OWNED_BY_USERID) " + + "SELECT OFFICE_CODE, ?, ?, ? FROM CWMS_OFFICE WHERE OFFICE_ID = ?")) { + insertList.setString(1, listId); + insertList.setString(2, USER_LIST_DESC); + insertList.setString(3, owner); + insertList.setString(4, office); + insertList.executeUpdate(); } catch (SQLException ex) { throw new RuntimeException(ex); } }, "cwms_20"); } - @AfterEach - void deleteUserList() throws SQLException { + private void cleanUserLists() throws SQLException { CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); db.connection(c -> { try { - try (PreparedStatement deleteMembers = c.prepareStatement( - "DELETE FROM AT_USER_LIST_MEMBERS WHERE USER_LIST_ID = ?"); - PreparedStatement deleteList = c.prepareStatement( - "DELETE FROM AT_USER_LISTS WHERE USER_LIST_ID = ?")) { - deleteMembers.setString(1, USER_LIST_ID); - deleteMembers.executeUpdate(); - deleteList.setString(1, USER_LIST_ID); - deleteList.executeUpdate(); - } + execute(c, "DELETE FROM AT_USER_LIST_MEMBERS WHERE USER_LIST_ID LIKE '" + + USER_LIST_ID + "%'"); + execute(c, "DELETE FROM AT_USER_LISTS WHERE USER_LIST_ID LIKE '" + + USER_LIST_ID + "%'"); } catch (SQLException ex) { throw new RuntimeException(ex); } }, "cwms_20"); } - @ParameterizedTest - @ArgumentsSource(UserSpecSource.class) - @AuthType(user = TestAccounts.KeyUser.SPK_NORMAL2) - void test_get_user_list(String authType, TestAccounts.KeyUser theUser, RequestSpecification authSpec) { - given() - .log().ifValidationFails(LogDetail.ALL, true) - .spec(authSpec) - .queryParam("office", OFFICE) - .when() - .get("/user/list/{user-list-id}", USER_LIST_ID) - .then() - .log().ifValidationFails(LogDetail.ALL, true) - .statusCode(is(HttpCode.OK.getStatus())) - .body("office-id", equalTo(OFFICE)) - .body("user-list-id", equalTo(USER_LIST_ID)) - .body("description", equalTo(USER_LIST_DESC)); + private static void execute(java.sql.Connection connection, String sql) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.execute(); + } } - private static void executeIgnoreObjectExists(java.sql.Connection c, String sql) throws SQLException { - try (PreparedStatement stmt = c.prepareStatement(sql)) { - stmt.execute(); + private static void executeIgnoreObjectMissing(java.sql.Connection connection, String sql) + throws SQLException { + try { + execute(connection, sql); } catch (SQLException ex) { - String message = ex.getMessage(); - if (message == null || !(message.contains("ORA-00955") || message.contains("ORA-02260") - || message.contains("ORA-02261") || message.contains("ORA-02275"))) { + if (ex.getMessage() == null || !ex.getMessage().contains("ORA-00942")) { throw ex; } } } - private static void executeIgnoreInsufficientPrivilege(java.sql.Connection c, String sql) throws SQLException { - try (PreparedStatement stmt = c.prepareStatement(sql)) { - stmt.execute(); + private static void executeIgnoreInsufficientPrivilege(java.sql.Connection connection, + String sql) throws SQLException { + try { + execute(connection, sql); } catch (SQLException ex) { - String message = ex.getMessage(); - if (message == null || !message.contains("ORA-01031")) { + if (ex.getMessage() == null || !ex.getMessage().contains("ORA-01031")) { throw ex; } } diff --git a/docs/source/decisions/0013-cda-user-lists.md b/docs/source/decisions/0013-cda-user-lists.md index 0bfb69634c..9166428b22 100644 --- a/docs/source/decisions/0013-cda-user-lists.md +++ b/docs/source/decisions/0013-cda-user-lists.md @@ -1,6 +1,6 @@ # CDA User Lists -| Status | Proposed | +| Status | Accepted | | :-- | :-- | | **ADR #** | 0013 | | **Author** | Charles Graham | @@ -24,6 +24,10 @@ CDA exposes office-scoped user lists backed by the CWMS database objects `AT_USER_LISTS`, `AT_USER_LIST_MEMBERS`, and `AV_USER_LIST_MEMBERS`. Membership references existing `AT_SEC_CWMS_USERS` rows. +A list is uniquely identified by `(office-id, user-list-id)`. The same +`user-list-id` may therefore be used independently by multiple offices, and +membership rows carry the same office/list composite key. + The REST resource is rooted at `/user/list`: - `GET /user/list?office=...` lists an office's lists. @@ -34,8 +38,19 @@ The REST resource is rooted at `/user/list`: Any authenticated principal with the `CWMS Users` role may read list metadata and membership for any office. Mutations require `CWMS User Admins` membership for the -office named by the resource. CDA derives owner and audit user IDs from the -authenticated principal. +office named by the resource. This deliberately permits authenticated CDA clients +to resolve current member names and email addresses across offices; user lists +must not be used for information that should be hidden from other authenticated +CWMS users. + +CDA sets `owned-by-user-id` to the authenticated user who creates the list. The +client cannot provide or change that value. Ownership is immutable audit metadata; +it does not bypass or replace office-admin authorization for later mutations. +Member `added-by-user-id` audit values are derived the same way. + +List IDs are normalized to uppercase and limited to 128 letters, numbers, dots, +underscores, or hyphens. Descriptions are limited to the database column's 1,024 +characters. Duplicate lists and members return a conflict response. The `USER_LISTS` Togglz feature controls route exposure. CDA registers the documented handlers only when the feature is enabled. Requests are also guarded by @@ -68,6 +83,8 @@ relational. - List IDs are stable references while member identity and email values remain sourced from CWMS users. +- List IDs are unique within an office, not globally. +- The creating user remains visible as immutable audit metadata. - Office authorization is enforced by CDA rather than trusted to clients. - Deployments must enable the feature only after installing the required schema. - Future contact fields can be added to the membership view and DTO without @@ -75,6 +92,7 @@ relational. ## Implementation Status -The database schema was introduced through CWMS database PR 160. CDA branch -`1733-user-lists` implements the resource handlers, schema and feature gating, -office-aware authorization, and management UI described here. +The database schema was introduced through CWMS database PR 160. The CDA backend +branch implements the resource handlers, schema and feature gating, validation, +office-aware authorization, and creator audit behavior described here. The stacked +CDA UI branch provides the authenticated management interface. From 956d775e630e41d2cd1640e2280b8d489d437789 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 24 Jul 2026 15:10:36 -0500 Subject: [PATCH 08/13] Add authenticated user list management UI Signed-off-by: Charles Graham, SWT --- cda-gui/.env.development | 4 +- cda-gui/.env.test | 4 +- cda-gui/src/components/AuthButton.jsx | 14 ++ cda-gui/src/components/Layout.jsx | 24 ++- cda-gui/src/links/header-links.js | 5 + cda-gui/src/main.jsx | 22 +- cda-gui/src/pages/user-lists/index.jsx | 268 +++++++++++++++++++++++++ cda-gui/src/route-paths.js | 5 + 8 files changed, 331 insertions(+), 15 deletions(-) create mode 100644 cda-gui/src/components/AuthButton.jsx create mode 100644 cda-gui/src/pages/user-lists/index.jsx diff --git a/cda-gui/.env.development b/cda-gui/.env.development index c86560b277..2835760128 100644 --- a/cda-gui/.env.development +++ b/cda-gui/.env.development @@ -1 +1,3 @@ -VITE_CDA_API_ROOT=https://water.dev.cwbi.us/cwms-data \ No newline at end of file +VITE_CDA_API_ROOT=https://water.dev.cwbi.us/cwms-data +VITE_AUTH_HOST=https://identity-test.cwbi.us/auth +VITE_AUTH_REALM=cwbi diff --git a/cda-gui/.env.test b/cda-gui/.env.test index 6f12fa2e4d..8d72983cb8 100644 --- a/cda-gui/.env.test +++ b/cda-gui/.env.test @@ -1 +1,3 @@ -VITE_CDA_API_ROOT=https://cwms-data-test.cwbi.us/cwms-data \ No newline at end of file +VITE_CDA_API_ROOT=https://cwms-data-test.cwbi.us/cwms-data +VITE_AUTH_HOST=https://identity-test.cwbi.us/auth +VITE_AUTH_REALM=cwbi diff --git a/cda-gui/src/components/AuthButton.jsx b/cda-gui/src/components/AuthButton.jsx new file mode 100644 index 0000000000..d8379a1ced --- /dev/null +++ b/cda-gui/src/components/AuthButton.jsx @@ -0,0 +1,14 @@ +import { useAuth } from "@usace-watermanagement/groundwork-water"; +import { LoginButton } from "@usace/groundwork"; + +export default function AuthButton() { + const auth = useAuth(); + + return auth.isAuth ? ( + + ) : ( + + ); +} diff --git a/cda-gui/src/components/Layout.jsx b/cda-gui/src/components/Layout.jsx index f81536bbb2..e33b0423de 100644 --- a/cda-gui/src/components/Layout.jsx +++ b/cda-gui/src/components/Layout.jsx @@ -5,6 +5,7 @@ import footerLinks from "../links/footer-links"; import externalLinks from "../links/external-links"; import Breadcrumbs from "./Breadcrumbs"; import { FaGithub } from "react-icons/fa"; +import AuthButton from "./AuthButton"; export default function Layout() { return ( @@ -15,16 +16,19 @@ export default function Layout() { subtitle="CWMS Restful API for Data Retrieval" aboutText="Deliver vital engineering solutions, in collaboration with our partners, to secure our Nation, energize our economy, and reduce disaster risk. The official public website of the U.S. Army Corps of Engineers Hydrologic Engineering Center (HEC)." navRight={ - +
+ + +
} usaceLinks={footerLinks} externalLinks={externalLinks} diff --git a/cda-gui/src/links/header-links.js b/cda-gui/src/links/header-links.js index c66f468c0c..9e6e03515b 100644 --- a/cda-gui/src/links/header-links.js +++ b/cda-gui/src/links/header-links.js @@ -38,6 +38,11 @@ export default [ }, ], }, + { + id: "user-lists", + text: "User Lists", + href: "/user-lists", + }, { id: "help", text: "Help", diff --git a/cda-gui/src/main.jsx b/cda-gui/src/main.jsx index 95e0386c15..674cef5721 100644 --- a/cda-gui/src/main.jsx +++ b/cda-gui/src/main.jsx @@ -5,6 +5,10 @@ import { Link, createBrowserRouter, RouterProvider } from "react-router-dom"; import { LinkProvider } from "@usace/groundwork"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + AuthProvider, + createKeycloakAuthMethod, +} from "@usace-watermanagement/groundwork-water"; // Pages import Home from "./pages/Home"; @@ -22,9 +26,18 @@ import ErrorFallback from "./pages/ErrorFallback"; import FilterExpressions from "./pages/rsql"; import Timestamps from "./pages/timestamps"; import LegacyFormat from "./pages/legacy-format/index.jsx"; +import UserLists from "./pages/user-lists/index.jsx"; import { routePaths } from "./route-paths"; const queryClient = new QueryClient(); +const authMethod = createKeycloakAuthMethod({ + host: import.meta.env.VITE_AUTH_HOST, + realm: import.meta.env.VITE_AUTH_REALM, + client: "cwms", + flow: "authorization-code-pkce", + redirectUri: window.location.href, + providerHint: "federation-eams", +}); const routeComponents = { home: Home, "swagger-ui": SwaggerUI, @@ -34,6 +47,7 @@ const routeComponents = { timestamps: Timestamps, "legacy-format": LegacyFormat, "location-search": LocationSearch, + "user-lists": UserLists, }; const router = createBrowserRouter( @@ -59,9 +73,11 @@ const router = createBrowserRouter( ReactDOM.createRoot(document.getElementById("root")).render( - - - + + + + + , ); diff --git a/cda-gui/src/pages/user-lists/index.jsx b/cda-gui/src/pages/user-lists/index.jsx new file mode 100644 index 0000000000..9c711fdf76 --- /dev/null +++ b/cda-gui/src/pages/user-lists/index.jsx @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useAuth } from "@usace-watermanagement/groundwork-water"; + +const apiRoot = import.meta.env.VITE_CDA_API_ROOT.replace(/\/$/, ""); + +async function request(path, token, options = {}) { + const response = await fetch(`${apiRoot}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }); + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `${response.status} ${response.statusText}`); + } + return response.status === 204 ? null : response.json(); +} + +function values(payload) { + return payload?.["user-lists"] ?? payload?.entries ?? payload ?? []; +} + +export default function UserLists() { + const auth = useAuth(); + const [profile, setProfile] = useState(null); + const [office, setOffice] = useState(""); + const [lists, setLists] = useState([]); + const [selected, setSelected] = useState(""); + const [members, setMembers] = useState([]); + const [newList, setNewList] = useState(""); + const [description, setDescription] = useState(""); + const [newMember, setNewMember] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + const offices = useMemo( + () => Object.keys(profile?.roles ?? profile?.["office-roles"] ?? {}).sort(), + [profile], + ); + const roles = profile?.roles?.[office] ?? profile?.["office-roles"]?.[office] ?? []; + const canWrite = roles.includes("CWMS User Admins"); + + const loadLists = useCallback( + async (targetOffice = office) => { + if (!targetOffice) return; + const payload = await request( + `/user/list?office=${encodeURIComponent(targetOffice)}`, + auth.token, + ); + const nextLists = values(payload); + setLists(nextLists); + setSelected((current) => + nextLists.some((item) => item["user-list-id"] === current) + ? current + : (nextLists[0]?.["user-list-id"] ?? ""), + ); + }, + [auth.token, office], + ); + + useEffect(() => { + if (!auth.isAuth || !auth.token) return; + request("/user/profile", auth.token) + .then((nextProfile) => { + setProfile(nextProfile); + const nextOffice = Object.keys( + nextProfile?.roles ?? nextProfile?.["office-roles"] ?? {}, + ).sort()[0]; + setOffice((current) => current || nextOffice || ""); + }) + .catch((cause) => setError(cause.message)); + }, [auth.isAuth, auth.token]); + + useEffect(() => { + setError(""); + loadLists().catch((cause) => setError(cause.message)); + }, [loadLists]); + + useEffect(() => { + if (!selected || !office || !auth.token) { + setMembers([]); + return; + } + request( + `/user/list/${encodeURIComponent(selected)}/members?office=${encodeURIComponent(office)}`, + auth.token, + ) + .then((payload) => setMembers(payload?.members ?? payload ?? [])) + .catch((cause) => setError(cause.message)); + }, [selected, office, auth.token]); + + async function mutate(action, success) { + setError(""); + setMessage(""); + try { + await action(); + setMessage(success); + await loadLists(); + } catch (cause) { + setError(cause.message); + } + } + + if (!auth.isAuth) { + return ( +
+

User Lists

+

Log in to view and manage reusable CDA user lists.

+ +
+ ); + } + + return ( +
+

User Lists

+

+ Reusable lists are available to authenticated CDA users. Creating and changing a + list requires the CWMS User Admins role for its office. +

+ {error &&

{error}

} + {message &&

{message}

} + + + +
+
+

Lists

+ {lists.length === 0 ? ( +

No user lists have been created for this office.

+ ) : ( + + )} + {canWrite && ( +
{ + event.preventDefault(); + mutate( + () => + request("/user/list", auth.token, { + method: "POST", + body: JSON.stringify({ + "office-id": office, + "user-list-id": newList, + description, + }), + }), + `Created ${newList}.`, + ).then(() => { + setSelected(newList); + setNewList(""); + setDescription(""); + }); + }} + > +

Create a list

+ setNewList(event.target.value)} + /> + setDescription(event.target.value)} + /> + +
+ )} +
+ +
+

{selected ? `${selected} members` : "Members"}

+ {members.length === 0 ? ( +

This list has no members.

+ ) : ( +
    + {members.map((member) => { + const userId = member["user-id"] ?? member; + return ( +
  • + {userId}{" "} + {canWrite && ( + + )} +
  • + ); + })} +
+ )} + {canWrite && selected && ( +
{ + event.preventDefault(); + mutate( + () => + request( + `/user/list/${encodeURIComponent(selected)}/members?office=${encodeURIComponent(office)}`, + auth.token, + { + method: "POST", + body: JSON.stringify({ "user-id": newMember }), + }, + ), + `Added ${newMember}.`, + ).then(() => setNewMember("")); + }} + > + setNewMember(event.target.value)} + /> + +
+ )} +
+
+
+ ); +} diff --git a/cda-gui/src/route-paths.js b/cda-gui/src/route-paths.js index 9d4f5b5d03..497180dd99 100644 --- a/cda-gui/src/route-paths.js +++ b/cda-gui/src/route-paths.js @@ -30,6 +30,11 @@ export const routePaths = [ path: "timestamps", sitemapPath: "timestamps", }, + { + id: "user-lists", + path: "user-lists", + sitemapPath: "user-lists", + }, { id: "legacy-format", path: "legacy-format", From 873dceaef2e56f2672add6e631ab0875041b9548 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 24 Jul 2026 17:00:33 -0500 Subject: [PATCH 09/13] Allow bearer headers in CDA browser requests --- cwms-data-api/src/main/webapp/WEB-INF/web.xml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cwms-data-api/src/main/webapp/WEB-INF/web.xml b/cwms-data-api/src/main/webapp/WEB-INF/web.xml index 6b434b56d3..99e5889d3f 100644 --- a/cwms-data-api/src/main/webapp/WEB-INF/web.xml +++ b/cwms-data-api/src/main/webapp/WEB-INF/web.xml @@ -25,10 +25,14 @@ cors.allowed.origins * - - cors.allowed.methods - GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS - + + cors.allowed.methods + GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS + + + cors.allowed.headers + Authorization,Content-Type,Accept,Origin,X-Requested-With + CorsFilter From 0575f7fd36b14b17f26bb3db8acbeb881cbd4255 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 24 Jul 2026 17:31:06 -0500 Subject: [PATCH 10/13] Enable local authenticated CDA user-list UI --- .gitignore | 3 +- cda-gui/.env.dev-cda-compose | 4 +++ cda-gui/src/main.jsx | 54 ++++++++++++++++++++++++++++++------ 3 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 cda-gui/.env.dev-cda-compose diff --git a/.gitignore b/.gitignore index b9f9a61062..84a9c61734 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ etl.env cwms-data-api/features.properties cda-etl/logs cda-etl/cache -**/.venv \ No newline at end of file +**/.venv +cda-gui/.env.dev-cda-compose.local diff --git a/cda-gui/.env.dev-cda-compose b/cda-gui/.env.dev-cda-compose new file mode 100644 index 0000000000..f50751ba9b --- /dev/null +++ b/cda-gui/.env.dev-cda-compose @@ -0,0 +1,4 @@ +VITE_CDA_API_ROOT=/cwms-data +CDA_DEV_PROXY_ROOT=http://localhost:8081 +VITE_AUTH_HOST=http://localhost:8081/auth +VITE_AUTH_REALM=cwms diff --git a/cda-gui/src/main.jsx b/cda-gui/src/main.jsx index 674cef5721..a4c10bcd73 100644 --- a/cda-gui/src/main.jsx +++ b/cda-gui/src/main.jsx @@ -30,14 +30,52 @@ import UserLists from "./pages/user-lists/index.jsx"; import { routePaths } from "./route-paths"; const queryClient = new QueryClient(); -const authMethod = createKeycloakAuthMethod({ - host: import.meta.env.VITE_AUTH_HOST, - realm: import.meta.env.VITE_AUTH_REALM, - client: "cwms", - flow: "authorization-code-pkce", - redirectUri: window.location.href, - providerHint: "federation-eams", -}); + +function createLocalAuthMethod() { + let token; + return { + async login() { + const response = await fetch( + `${import.meta.env.VITE_AUTH_HOST}/realms/${import.meta.env.VITE_AUTH_REALM}/protocol/openid-connect/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "password", + client_id: "cwms", + username: import.meta.env.VITE_AUTH_USER, + password: import.meta.env.VITE_AUTH_PASSWORD, + }), + }, + ); + if (!response.ok) { + throw new Error(`Local Keycloak login failed (${response.status})`); + } + token = (await response.json()).access_token; + }, + async logout() { + token = undefined; + }, + async isAuth() { + return !!token; + }, + get token() { + return token; + }, + }; +} + +const authMethod = + import.meta.env.MODE === "dev-cda-compose" + ? createLocalAuthMethod() + : createKeycloakAuthMethod({ + host: import.meta.env.VITE_AUTH_HOST, + realm: import.meta.env.VITE_AUTH_REALM, + client: "cwms", + flow: "authorization-code-pkce", + redirectUri: window.location.href, + providerHint: "federation-eams", + }); const routeComponents = { home: Home, "swagger-ui": SwaggerUI, From 3a9d24a098c5efd36cf3edb08a0efbd60d28bd77 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 24 Jul 2026 17:31:19 -0500 Subject: [PATCH 11/13] Polish user list management with Groundwork --- cda-gui/src/pages/user-lists/index.jsx | 687 ++++++++++++++++++------- 1 file changed, 514 insertions(+), 173 deletions(-) diff --git a/cda-gui/src/pages/user-lists/index.jsx b/cda-gui/src/pages/user-lists/index.jsx index 9c711fdf76..324daaaef4 100644 --- a/cda-gui/src/pages/user-lists/index.jsx +++ b/cda-gui/src/pages/user-lists/index.jsx @@ -1,5 +1,32 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Badge, + Button, + Card, + DeleteConfirm, + Description, + Dropdown, + Field, + H1, + H2, + H3, + Input, + Label, + Modal, + Skeleton, + Strong, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, + Textarea, +} from "@usace/groundwork"; import { useAuth } from "@usace-watermanagement/groundwork-water"; +import PropTypes from "prop-types"; +import { FaListUl, FaPlus, FaUserPlus, FaUsers } from "react-icons/fa"; const apiRoot = import.meta.env.VITE_CDA_API_ROOT.replace(/\/$/, ""); @@ -7,14 +34,21 @@ async function request(path, token, options = {}) { const response = await fetch(`${apiRoot}${path}`, { ...options, headers: { - "Content-Type": "application/json", + Accept: "application/json", Authorization: `Bearer ${token}`, + ...(options.body ? { "Content-Type": "application/json" } : {}), ...options.headers, }, }); if (!response.ok) { - const detail = await response.text(); - throw new Error(detail || `${response.status} ${response.statusText}`); + let detail = `${response.status} ${response.statusText}`.trim(); + try { + const payload = await response.json(); + detail = payload.message ?? payload.detail ?? detail; + } catch { + // Keep the HTTP status when CDA does not return a JSON error body. + } + throw new Error(detail); } return response.status === 204 ? null : response.json(); } @@ -23,6 +57,49 @@ function values(payload) { return payload?.["user-lists"] ?? payload?.entries ?? payload ?? []; } +function Notice({ kind, children }) { + const isError = kind === "error"; + return ( +
+ {isError ? ( + {children} + ) : ( + {children} + )} +
+ ); +} + +function EmptyState({ icon: Icon, title, children }) { + return ( +
+
+
+

{title}

+ {children} +
+ ); +} + +Notice.propTypes = { + kind: PropTypes.oneOf(["error", "success"]).isRequired, + children: PropTypes.node.isRequired, +}; + +EmptyState.propTypes = { + icon: PropTypes.elementType.isRequired, + title: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + export default function UserLists() { const auth = useAuth(); const [profile, setProfile] = useState(null); @@ -35,6 +112,10 @@ export default function UserLists() { const [newMember, setNewMember] = useState(""); const [message, setMessage] = useState(""); const [error, setError] = useState(""); + const [listsLoading, setListsLoading] = useState(false); + const [membersLoading, setMembersLoading] = useState(false); + const [working, setWorking] = useState(false); + const [createOpened, setCreateOpened] = useState(false); const offices = useMemo( () => Object.keys(profile?.roles ?? profile?.["office-roles"] ?? {}).sort(), @@ -42,25 +123,54 @@ export default function UserLists() { ); const roles = profile?.roles?.[office] ?? profile?.["office-roles"]?.[office] ?? []; const canWrite = roles.includes("CWMS User Admins"); + const selectedList = lists.find((item) => item["user-list-id"] === selected); const loadLists = useCallback( async (targetOffice = office) => { - if (!targetOffice) return; - const payload = await request( - `/user/list?office=${encodeURIComponent(targetOffice)}`, - auth.token, - ); - const nextLists = values(payload); - setLists(nextLists); - setSelected((current) => - nextLists.some((item) => item["user-list-id"] === current) - ? current - : (nextLists[0]?.["user-list-id"] ?? ""), - ); + if (!targetOffice) return []; + setListsLoading(true); + try { + const payload = await request( + `/user/list?office=${encodeURIComponent(targetOffice)}`, + auth.token, + ); + const nextLists = values(payload); + setLists(nextLists); + setSelected((current) => + nextLists.some((item) => item["user-list-id"] === current) + ? current + : (nextLists[0]?.["user-list-id"] ?? ""), + ); + return nextLists; + } finally { + setListsLoading(false); + } }, [auth.token, office], ); + const loadMembers = useCallback( + async (userListId = selected) => { + if (!userListId || !office || !auth.token) { + setMembers([]); + return []; + } + setMembersLoading(true); + try { + const payload = await request( + `/user/list/${encodeURIComponent(userListId)}/members?office=${encodeURIComponent(office)}`, + auth.token, + ); + const nextMembers = payload?.members ?? payload ?? []; + setMembers(nextMembers); + return nextMembers; + } finally { + setMembersLoading(false); + } + }, + [auth.token, office, selected], + ); + useEffect(() => { if (!auth.isAuth || !auth.token) return; request("/user/profile", auth.token) @@ -80,189 +190,420 @@ export default function UserLists() { }, [loadLists]); useEffect(() => { - if (!selected || !office || !auth.token) { - setMembers([]); - return; - } - request( - `/user/list/${encodeURIComponent(selected)}/members?office=${encodeURIComponent(office)}`, - auth.token, - ) - .then((payload) => setMembers(payload?.members ?? payload ?? [])) - .catch((cause) => setError(cause.message)); - }, [selected, office, auth.token]); + setError(""); + loadMembers().catch((cause) => setError(cause.message)); + }, [loadMembers]); - async function mutate(action, success) { + async function mutate(action, success, refresh = {}) { setError(""); setMessage(""); + setWorking(true); try { - await action(); + const result = await action(); + if (refresh.lists !== false) await loadLists(); + if (refresh.members) await loadMembers(refresh.members); setMessage(success); - await loadLists(); + return result; } catch (cause) { setError(cause.message); + return null; + } finally { + setWorking(false); + } + } + + async function createList(event) { + event.preventDefault(); + const listId = newList.trim().toUpperCase(); + const created = await mutate( + () => + request("/user/list", auth.token, { + method: "POST", + body: JSON.stringify({ + "office-id": office, + "user-list-id": listId, + description: description.trim() || null, + }), + }), + `Created ${listId}.`, + ); + if (created) { + setSelected(listId); + setNewList(""); + setDescription(""); + setCreateOpened(false); } } if (!auth.isAuth) { return ( -
-

User Lists

-

Log in to view and manage reusable CDA user lists.

- -
+ + ); } return ( -
-

User Lists

-

- Reusable lists are available to authenticated CDA users. Creating and changing a - list requires the CWMS User Admins role for its office. -

- {error &&

{error}

} - {message &&

{message}

} +
+
+
+
+ CDA + + {canWrite ? "User list administrator" : "Read only"} + +
+

User Lists

+ + Build reusable, office-owned groups for notifications and other CDA-aware + applications. Membership stays in CDA so every consumer uses the same list. + +
+ {canWrite && ( + + )} +
- + {error && {error}} + {message && {message}} -
-
-

Lists

- {lists.length === 0 ? ( -

No user lists have been created for this office.

- ) : ( - - )} - {canWrite && ( -
{ - event.preventDefault(); - mutate( - () => - request("/user/list", auth.token, { - method: "POST", - body: JSON.stringify({ - "office-id": office, - "user-list-id": newList, - description, - }), - }), - `Created ${newList}.`, - ).then(() => { - setSelected(newList); - setNewList(""); - setDescription(""); - }); - }} - > -

Create a list

- setNewList(event.target.value)} + +
+ + + + User lists are isolated by their owning CWMS office. + + {offices.length > 0 ? ( + { + setOffice(event.target.value); + setSelected(""); + setMembers([]); + setMessage(""); + setError(""); + }} + options={offices.map((item) => ( + + ))} /> - setDescription(event.target.value)} - /> - - - )} + ) : ( + + )} + +
+ + {office || "No office selected"} + {canWrite + ? " administrators can create lists and update membership." + : " lists are available for viewing with your current role."} + +
+
-
-

{selected ? `${selected} members` : "Members"}

- {members.length === 0 ? ( -

This list has no members.

- ) : ( -
    - {members.map((member) => { - const userId = member["user-id"] ?? member; - return ( -
  • - {userId}{" "} - {canWrite && ( - - )} -
  • - ); - })} -
- )} - {canWrite && selected && ( -
{ - event.preventDefault(); - mutate( - () => - request( - `/user/list/${encodeURIComponent(selected)}/members?office=${encodeURIComponent(office)}`, - auth.token, - { - method: "POST", - body: JSON.stringify({ "user-id": newMember }), - }, - ), - `Added ${newMember}.`, - ).then(() => setNewMember("")); - }} - > - setNewMember(event.target.value)} +
+ +
+
+

Lists

+ Select a list to inspect its members. +
+ {lists.length} +
+ +
+ {listsLoading ? ( +
+ + +
+ ) : lists.length === 0 ? ( + + {canWrite + ? "Create the first reusable list for this office." + : "Ask a CWMS User Administrator to create a list for this office."} + + ) : ( +
+ {lists.map((item) => { + const listId = item["user-list-id"]; + const active = listId === selected; + return ( + + ); + })} +
+ )} +
+
+ + +
+
+
+

{selected || "Members"}

+ {selected && {members.length} members} +
+ + {selectedList?.description || + "Select a user list to view its current membership."} + +
+ {canWrite && selected && ( + + mutate( + () => + request( + `/user/list/${encodeURIComponent(selected)}?office=${encodeURIComponent(office)}`, + auth.token, + { method: "DELETE" }, + ), + `Deleted ${selected}.`, + ) + } /> - - + )} +
+ + {!selected ? ( +
+ + Select a user list from the left to see names, user IDs, and email + addresses. + +
+ ) : ( + <> + {canWrite && ( +
{ + event.preventDefault(); + const userId = newMember.trim().toUpperCase(); + const added = await mutate( + () => + request( + `/user/list/${encodeURIComponent(selected)}/members?office=${encodeURIComponent(office)}`, + auth.token, + { + method: "POST", + body: JSON.stringify({ "user-id": userId }), + }, + ), + `Added ${userId} to ${selected}.`, + { lists: false, members: selected }, + ); + if (added) setNewMember(""); + }} + > + + + + Enter an existing CWMS user ID. CDA supplies the display name and + email address. + +
+ setNewMember(event.target.value)} + /> + +
+
+
+ )} + +
+ {membersLoading ? ( +
+ + +
+ ) : members.length === 0 ? ( + + {canWrite + ? "Add a CWMS user to make this list available to notification consumers." + : "A list administrator has not added any members yet."} + + ) : ( + + + + User + Email + {canWrite && ( + Action + )} + + + + {members.map((member) => { + const userId = member["user-id"] ?? member; + return ( + + + {member["full-name"] || userId} + {member["full-name"] && ( + {userId} + )} + + + {member.email ? ( + + {member.email} + + ) : ( + No email in CDA profile + )} + + {canWrite && ( + + + + )} + + ); + })} + +
+ )} +
+ )} -
+
+ + setCreateOpened(false)} + dialogTitle="Create a user list" + dialogDescription={`Create an office-owned list for ${office}.`} + size="lg" + > +
+ + + + Use a short, recognizable name such as ON-CALL-HYDROLOGISTS. + + setNewList(event.target.value.toUpperCase())} + /> + + + + + Explain who belongs in the list and how it is used. + +