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/.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 ? (
+
+ Log out
+
+ ) : (
+
+ );
+}
diff --git a/cda-gui/src/components/HelpTip.jsx b/cda-gui/src/components/HelpTip.jsx
new file mode 100644
index 0000000000..bdfb4f8701
--- /dev/null
+++ b/cda-gui/src/components/HelpTip.jsx
@@ -0,0 +1,68 @@
+import { useId, useState } from "react";
+import PropTypes from "prop-types";
+import { FaCircleQuestion, FaXmark } from "react-icons/fa6";
+
+export function HelpTip({ title, children, className = "" }) {
+ const [open, setOpen] = useState(false);
+ const titleId = useId();
+
+ return (
+
+
setOpen(true)}
+ >
+
+
+ {open && (
+
{
+ if (event.key === "Escape") setOpen(false);
+ }}
+ >
+
setOpen(false)}
+ />
+
+
+
+ {title}
+
+ setOpen(false)}
+ >
+
+
+
+ {children}
+
+
+ )}
+
+ );
+}
+
+HelpTip.propTypes = {
+ title: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+ className: PropTypes.string,
+};
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={
-
- GitHub
-
+
}
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..a4c10bcd73 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,56 @@ 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();
+
+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,
@@ -34,6 +85,7 @@ const routeComponents = {
timestamps: Timestamps,
"legacy-format": LegacyFormat,
"location-search": LocationSearch,
+ "user-lists": UserLists,
};
const router = createBrowserRouter(
@@ -59,9 +111,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..c533ad4aa0
--- /dev/null
+++ b/cda-gui/src/pages/user-lists/index.jsx
@@ -0,0 +1,902 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ Badge,
+ Button,
+ Card,
+ 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, FaPen, FaPlus, FaSearch, FaUserPlus, FaUsers } from "react-icons/fa";
+import { HelpTip } from "../../components/HelpTip";
+
+const apiRoot = import.meta.env.VITE_CDA_API_ROOT.replace(/\/$/, "");
+
+async function request(path, token, options = {}) {
+ const response = await fetch(`${apiRoot}${path}`, {
+ ...options,
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
+ ...options.headers,
+ },
+ });
+ if (!response.ok) {
+ 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();
+}
+
+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);
+ const [profileLoading, setProfileLoading] = useState(true);
+ 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 [candidateSearch, setCandidateSearch] = useState("");
+ const [candidates, setCandidates] = useState([]);
+ const [candidateLoading, setCandidateLoading] = useState(false);
+ const [listSearch, setListSearch] = useState("");
+ const [memberSearch, setMemberSearch] = 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 [editOpened, setEditOpened] = useState(false);
+ const [editDescription, setEditDescription] = useState("");
+ const [deleteOpened, setDeleteOpened] = useState(false);
+
+ 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 selectedList = lists.find((item) => item["user-list-id"] === selected);
+ const filteredLists = useMemo(() => {
+ const term = listSearch.trim().toLowerCase();
+ if (!term) return lists;
+ return lists.filter(
+ (item) =>
+ item["user-list-id"]?.toLowerCase().includes(term) ||
+ item.description?.toLowerCase().includes(term),
+ );
+ }, [listSearch, lists]);
+ const filteredMembers = useMemo(() => {
+ const term = memberSearch.trim().toLowerCase();
+ if (!term) return members;
+ return members.filter((member) =>
+ [member["user-id"], member["full-name"], member.email]
+ .filter(Boolean)
+ .some((value) => value.toLowerCase().includes(term)),
+ );
+ }, [memberSearch, members]);
+
+ const loadLists = useCallback(
+ async (targetOffice = office) => {
+ 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)
+ .then((nextProfile) => {
+ setProfile(nextProfile);
+ const nextOffice = Object.keys(
+ nextProfile?.roles ?? nextProfile?.["office-roles"] ?? {},
+ ).sort()[0];
+ setOffice((current) => current || nextOffice || "");
+ })
+ .catch((cause) => setError(cause.message))
+ .finally(() => setProfileLoading(false));
+ }, [auth.isAuth, auth.token]);
+
+ useEffect(() => {
+ setError("");
+ loadLists().catch((cause) => setError(cause.message));
+ }, [loadLists]);
+
+ useEffect(() => {
+ setError("");
+ loadMembers().catch((cause) => setError(cause.message));
+ }, [loadMembers]);
+
+ useEffect(() => {
+ const search = candidateSearch.trim();
+ if (!auth.token || !office || search.length < 2) {
+ setCandidates([]);
+ setCandidateLoading(false);
+ return undefined;
+ }
+ const controller = new AbortController();
+ setError("");
+ setCandidates([]);
+ const timeout = window.setTimeout(async () => {
+ setCandidateLoading(true);
+ try {
+ const parameters = new URLSearchParams({
+ office,
+ search,
+ "page-size": "20",
+ });
+ const payload = await request(
+ `/user/list-member-candidates?${parameters}`,
+ auth.token,
+ { signal: controller.signal },
+ );
+ setCandidates(payload?.candidates ?? payload ?? []);
+ } catch (cause) {
+ if (cause.name !== "AbortError") setError(cause.message);
+ } finally {
+ if (!controller.signal.aborted) setCandidateLoading(false);
+ }
+ }, 250);
+ return () => {
+ window.clearTimeout(timeout);
+ controller.abort();
+ };
+ }, [auth.token, candidateSearch, office]);
+
+ async function mutate(action, success, refresh = {}) {
+ setError("");
+ setMessage("");
+ setWorking(true);
+ try {
+ const result = await action();
+ if (refresh.lists !== false) await loadLists();
+ if (refresh.members) await loadMembers(refresh.members);
+ setMessage(success);
+ return result ?? true;
+ } 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
+
+ Sign in to view reusable recipient lists and manage membership for your
+ authorized CWMS offices.
+
+
+ Log in
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ CDA
+
+ {canWrite ? "User list administrator" : "Read only"}
+
+
+
+
User Lists
+
+ CDA records the authenticated creator as the list owner for audit history.
+ The owner does not change when the description or membership changes;
+ office User Administrators control edits.
+
+
+
+ Build reusable, office-owned lists for notifications and other CDA-aware
+ applications. Membership stays in CDA so every consumer uses the same list.
+
+
+ {canWrite && (
+
setCreateOpened(true)}>
+
+ New list
+
+ )}
+
+
+ {error && {error} }
+ {message && {message} }
+
+
+
+
+
+ Office
+
+ Each office owns a separate collection of lists. The same list ID may
+ exist in two offices without sharing members. Your office role
+ determines whether you can view or edit a list.
+
+
+
+ User lists are isolated by their owning CWMS office.
+
+ {offices.length > 0 ? (
+ {
+ setOffice(event.target.value);
+ setSelected("");
+ setMembers([]);
+ setMessage("");
+ setError("");
+ }}
+ options={offices.map((item) => (
+
+ {item}
+
+ ))}
+ />
+ ) : profileLoading ? (
+
+ ) : (
+ No authorized CWMS offices are available.
+ )}
+
+
+
+ {office || "No office selected"}
+ {canWrite
+ ? " administrators can create lists and update membership."
+ : " lists are available for viewing with your current role."}
+
+
+
+
+
+
+
+
+
+
Lists
+ Select a list to inspect its members.
+
+
{lists.length}
+
+
+
+ {lists.length > 0 && (
+
+
+ setListSearch(event.target.value)}
+ />
+
+ )}
+ {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."}
+
+ ) : filteredLists.length === 0 ? (
+
+ Try a different list ID or description.
+
+ ) : (
+
+ {filteredLists.map((item) => {
+ const listId = item["user-list-id"];
+ const active = listId === selected;
+ return (
+
{
+ setSelected(listId);
+ setMessage("");
+ setError("");
+ }}
+ >
+
+ {listId}
+ {active && Selected }
+
+
+ {item.description || "No description provided."}
+
+ {item["owned-by-user-id"] && (
+
+ Created by {item["owned-by-user-id"]}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+
+
{selected || "Members"}
+ {selected && {members.length} members }
+
+
+ {selectedList?.description ||
+ "Select a user list to view its current membership."}
+
+
+ {canWrite && selected && (
+
+ {
+ setEditDescription(selectedList?.description ?? "");
+ setEditOpened(true);
+ }}
+ >
+
+ Edit details
+
+ setDeleteOpened(true)}
+ >
+ Delete list
+
+
+ )}
+
+
+ {!selected ? (
+
+
+ Select a user list from the left to see names, user IDs, and email
+ addresses.
+
+
+ ) : (
+ <>
+ {canWrite && (
+
+ )}
+
+
+ {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."}
+
+ ) : (
+ <>
+
+
+ setMemberSearch(event.target.value)}
+ />
+
+ {filteredMembers.length === 0 ? (
+
+ Try a different name, user ID, or email.
+
+ ) : (
+
+
+
+
+ User
+ Email
+ {canWrite && (
+ Action
+ )}
+
+
+
+ {filteredMembers.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 && (
+
+
+ mutate(
+ () =>
+ request(
+ `/user/list/${encodeURIComponent(selected)}/members/${encodeURIComponent(userId)}?office=${encodeURIComponent(office)}`,
+ auth.token,
+ { method: "DELETE" },
+ ),
+ `Removed ${userId} from ${selected}.`,
+ { lists: false, members: selected },
+ )
+ }
+ >
+ Remove
+
+
+ )}
+
+ );
+ })}
+
+
+
+ )}
+ >
+ )}
+
+ >
+ )}
+
+
+
+ setCreateOpened(false)}
+ dialogTitle="Create a user list"
+ dialogDescription={`Create an office-owned list for ${office}.`}
+ size="lg"
+ >
+
+
+
+ setEditOpened(false)}
+ dialogTitle="Edit list details"
+ dialogDescription={`Update the description for ${selected}. The list ID and creator do not change.`}
+ size="lg"
+ >
+ {
+ event.preventDefault();
+ const updated = await mutate(
+ () =>
+ request(
+ `/user/list/${encodeURIComponent(selected)}?office=${encodeURIComponent(office)}`,
+ auth.token,
+ {
+ method: "PATCH",
+ body: JSON.stringify({
+ description: editDescription.trim() || null,
+ }),
+ },
+ ),
+ `Updated ${selected}.`,
+ );
+ if (updated) setEditOpened(false);
+ }}
+ >
+
+ Description
+
+ Explain who belongs in the list and how it is used.
+
+ setEditDescription(event.target.value)}
+ />
+
+
+ setEditOpened(false)}>
+ Cancel
+
+
+ Save changes
+
+
+
+
+
+ setDeleteOpened(false)}
+ dialogTitle="Delete user list?"
+ dialogDescription={`Delete ${selected} and remove all of its memberships. This cannot be undone.`}
+ size="md"
+ >
+
+ setDeleteOpened(false)}>
+ Cancel
+
+ {
+ const deleted = await mutate(
+ () =>
+ request(
+ `/user/list/${encodeURIComponent(selected)}?office=${encodeURIComponent(office)}`,
+ auth.token,
+ { method: "DELETE" },
+ ),
+ `Deleted ${selected}.`,
+ );
+ if (deleted) setDeleteOpened(false);
+ }}
+ >
+ Delete list
+
+
+
+
+ );
+}
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",
diff --git a/cda-gui/vite.config.js b/cda-gui/vite.config.js
index c420ebc5ea..77b09e58e3 100644
--- a/cda-gui/vite.config.js
+++ b/cda-gui/vite.config.js
@@ -15,7 +15,7 @@ export default defineConfig(({ mode }) => {
plugins: [react()],
server: {
proxy: {
- "^/(auth|CWMSLogin|cwms-data/(?!swagger-ui(?:/|$)|assets/|src/|node_modules/|@).*)":
+ "^/(auth|CWMSLogin|cwms-data/(?!$|swagger-ui(?:/|$)|data-query(?:/|$)|regexp(?:/|$)|filter-expressions(?:/|$)|timestamps(?:/|$)|user-lists(?:/|$)|legacy-format(?:/|$)|location-search(?:/|$)|assets/|src/|node_modules/|@).*)":
{
target: cdaApiRoot,
changeOrigin: true,
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 57a679c47e..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,6 +105,16 @@
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;
import cwms.cda.api.auth.users.roles.GetRolesController;
@@ -187,6 +197,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;
@@ -229,6 +240,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;
@@ -672,11 +684,51 @@ 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 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 CreateUserListController(metrics), userRoles);
+ get(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 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);
+ 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(userListCandidatesPath, ApiServlet::hasAnyRole);
+ 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) {
+ 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 {
boolean retVal = roles.stream().anyMatch(p.getRoles()::contains);
if(!retVal) {
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/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
new file mode 100644
index 0000000000..a63958b933
--- /dev/null
+++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java
@@ -0,0 +1,101 @@
+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) {
+ 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()) {
+ throw new RequiredQueryParameterException(OFFICE);
+ }
+
+ final String officeId = ctx.queryParamAsClass(OFFICE, String.class)
+ .check(Office::validOfficeNotNull, "Invalid office provided")
+ .get();
+
+ String userListId = UserListSupport.validateUserListId(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: "
+ + 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/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..61fd276387
--- /dev/null
+++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListFeature.java
@@ -0,0 +1,34 @@
+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() {
+ }
+
+ /**
+ * 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()) {
+ 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/UserListMemberController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMemberController.java
new file mode 100644
index 0000000000..055a77008d
--- /dev/null
+++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMemberController.java
@@ -0,0 +1,66 @@
+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;
+
+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 UserListMemberController implements Handler {
+ private final MetricRegistry metrics;
+
+ 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, office);
+ if (dsl == null) {
+ return;
+ }
+ UserListDao dao = new UserListDao(dsl);
+ UserListSupport.requireOfficeAdmin(ctx, dao, office);
+ 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
new file mode 100644
index 0000000000..49fde3dbe0
--- /dev/null
+++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java
@@ -0,0 +1,98 @@
+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) {
+ 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()) {
+ throw new RequiredQueryParameterException(OFFICE);
+ }
+
+ office = ctx.queryParamAsClass(OFFICE, String.class)
+ .check(Office::validOfficeNotNull, "Invalid office provided")
+ .get();
+
+ String userListId = UserListSupport.validateUserListId(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);
+
+ 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/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..aef86f4d6d
--- /dev/null
+++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListSupport.java
@@ -0,0 +1,116 @@
+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 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() {
+ }
+
+ 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 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) {
+ return requireFeature(ctx, null);
+ }
+
+ static DSLContext requireFeature(Context ctx, String office) {
+ DSLContext dsl = getDslContext(ctx, office);
+ return UserListFeature.requireSupported(ctx, dsl) ? dsl : null;
+ }
+
+ 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)) {
+ 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..689e3d93a8
--- /dev/null
+++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListsController.java
@@ -0,0 +1,55 @@
+package cwms.cda.api.auth.userlists;
+
+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.UserLists;
+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 UserListsController implements Handler {
+ private final MetricRegistry metrics;
+
+ 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) {
+ getAll(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)));
+ }
+ }
+}
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..e6f55120e1
--- /dev/null
+++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java
@@ -0,0 +1,322 @@
+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.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 {
+
+ 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) {
+ this.dsl = dsl;
+ }
+
+ 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 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) {
+ 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);
+
+ Long resolvedOfficeCode = resolveOfficeCode(officeId);
+ dsl.insertInto(atUserLists)
+ .columns(listOfficeCode, listUserListId, listDescription, listOwner)
+ .values(resolvedOfficeCode, normalizeId(userListId), description, normalizeId(owner))
+ .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);
+ Long resolvedOfficeCode = resolveOfficeCode(officeId);
+ 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) {
+ 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);
+ 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))
+ .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);
+ }
+
+ 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);
+ }
+
+ 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)) {
+ 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 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(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))
+ .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 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(memberOfficeCode.eq(resolvedOfficeCode))
+ .and(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);
+ 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(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(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
new file mode 100644
index 0000000000..1dd18563cd
--- /dev/null
+++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java
@@ -0,0 +1,76 @@
+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 office-scoped identifier of the user list.", maxLength = 128)
+ private final String userListId;
+
+ @Schema(description = "The user list description.", maxLength = 1024)
+ private final String description;
+
+ @Schema(description = "The immutable user id of the authenticated principal that created "
+ + "the list.")
+ 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;
+
+ /**
+ * Creates an office-scoped user-list representation.
+ */
+ 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/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
new file mode 100644
index 0000000000..8d64cf1f46
--- /dev/null
+++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListInput.java
@@ -0,0 +1,45 @@
+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() {
+ }
+
+ 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/UserListMember.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java
new file mode 100644
index 0000000000..219c3ad750
--- /dev/null
+++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java
@@ -0,0 +1,66 @@
+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 office-scoped identifier of the user list.", maxLength = 128)
+ private final String userListId;
+
+ @JsonProperty(required = true)
+ @Schema(description = "The user identifier for the member.", maxLength = 128)
+ 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;
+
+ /**
+ * Creates an office-scoped user-list member representation.
+ */
+ 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/UserListMemberInput.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMemberInput.java
new file mode 100644
index 0000000000..7416d5feb7
--- /dev/null
+++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMemberInput.java
@@ -0,0 +1,22 @@
+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() {
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
+}
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);
+ }
+}
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);
+ }
+}
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
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
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..dad131e749
--- /dev/null
+++ b/cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java
@@ -0,0 +1,295 @@
+package cwms.cda.api.users;
+
+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.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;
+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;
+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 {
+ 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) NOT NULL, "
+ + "CREATED_AT TIMESTAMP DEFAULT current_timestamp NOT NULL, "
+ + "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;");
+ 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), "
+ + "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 "
+ + "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.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, 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);
+ }
+ }, "cwms_20");
+ }
+
+ @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 (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");
+ }
+
+ private void cleanUserLists() throws SQLException {
+ CwmsDatabaseContainer> db = CwmsDataApiSetupCallback.getDatabaseLink();
+ db.connection(c -> {
+ try {
+ 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");
+ }
+
+ private static void execute(java.sql.Connection connection, String sql) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.execute();
+ }
+ }
+
+ private static void executeIgnoreObjectMissing(java.sql.Connection connection, String sql)
+ throws SQLException {
+ try {
+ execute(connection, sql);
+ } catch (SQLException ex) {
+ if (ex.getMessage() == null || !ex.getMessage().contains("ORA-00942")) {
+ throw ex;
+ }
+ }
+ }
+
+ private static void executeIgnoreInsufficientPrivilege(java.sql.Connection connection,
+ String sql) throws SQLException {
+ try {
+ execute(connection, sql);
+ } catch (SQLException ex) {
+ 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
new file mode 100644
index 0000000000..9166428b22
--- /dev/null
+++ b/docs/source/decisions/0013-cda-user-lists.md
@@ -0,0 +1,98 @@
+# CDA User Lists
+
+| Status | Accepted |
+| :-- | :-- |
+| **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.
+
+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.
+- `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. 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
+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.
+- 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
+ changing the core list-to-user relationship.
+
+## Implementation Status
+
+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.
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>