From 92e95cc42270a4dbadad93050cc0eed5fad0daab Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 16 Apr 2026 13:27:32 -0700 Subject: [PATCH 1/2] Fix legacy user accounts with email-as-UUID blocking project membership (#1545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserCreate was calling new User(email), which resolves to User(String uuid) and stored the email as the account's UUID primary key. Those accounts' getUUID() returns an email, so the project autocomplete posts an email-shaped id; ProjectUpdate/ProjectCreate then fall through to a username lookup that fails, and silently report success. - UserCreate: use new User(email, Util.generateUUID()) to stop creating more broken rows. - User(String uuid): @Deprecated — callers have misused it historically. - ProjectUpdate/ProjectCreate: add a shared resolveUser() with an email-address fallback for legacy accounts, log every fallback hit, and surface unresolvedUserIds in the response so failures are visible. Does not migrate existing bad rows — that follows in a separate ticket with a JSP-driven, admin-auth'd, dry-run-capable data migration that also reindexes affected encounter/occurrence docs in OpenSearch. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/org/ecocean/User.java | 7 +++ .../org/ecocean/servlet/ProjectCreate.java | 19 +++++--- .../org/ecocean/servlet/ProjectUpdate.java | 45 ++++++++++++++++--- .../java/org/ecocean/servlet/UserCreate.java | 5 ++- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/ecocean/User.java b/src/main/java/org/ecocean/User.java index 54dd0205b8..3bf0c107ed 100644 --- a/src/main/java/org/ecocean/User.java +++ b/src/main/java/org/ecocean/User.java @@ -115,6 +115,13 @@ public User(String email, String uuid) { this.lastLogin = -1; } + /** + * @deprecated GH-1545: Callers have historically misused this constructor by passing + * an email address where a UUID is expected, producing accounts whose primary key + * is the email. Prefer {@code new User(email, Util.generateUUID())} or another + * constructor when you do not already have a real UUID. + */ + @Deprecated public User(String uuid) { this.uuid = uuid; setReceiveEmails(false); diff --git a/src/main/java/org/ecocean/servlet/ProjectCreate.java b/src/main/java/org/ecocean/servlet/ProjectCreate.java index 9393f649fb..722e92084a 100644 --- a/src/main/java/org/ecocean/servlet/ProjectCreate.java +++ b/src/main/java/org/ecocean/servlet/ProjectCreate.java @@ -112,17 +112,24 @@ public void doPost(HttpServletRequest request, HttpServletResponse response) } } if (projectUserIds != null && projectUserIds.length() > 0) { + JSONArray unresolved = res.optJSONArray("unresolvedUserIds"); + if (unresolved == null) { + unresolved = new JSONArray(); + res.put("unresolvedUserIds", unresolved); + } for (int i = 0; i < projectUserIds.length(); i++) { String userIdentifier = projectUserIds.getString(i); if (!Util.stringIsEmptyOrNull(userIdentifier)) { - User user = null; - if (Util.isUUID(userIdentifier)) { - user = myShepherd.getUserByUUID(userIdentifier); - } else { - user = myShepherd.getUser(userIdentifier); - } + // GH-1545: use shared resolver with email-address fallback for + // legacy accounts whose UUID primary key stores an email. + User user = ProjectUpdate.resolveUser(myShepherd, userIdentifier); if (user != null) { newProject.addUser(user); + } else { + unresolved.put(userIdentifier); + System.out.println( + "ProjectCreate: could not resolve userIdentifier=" + + userIdentifier); } } } diff --git a/src/main/java/org/ecocean/servlet/ProjectUpdate.java b/src/main/java/org/ecocean/servlet/ProjectUpdate.java index bdc95b5356..02cbc0eee0 100644 --- a/src/main/java/org/ecocean/servlet/ProjectUpdate.java +++ b/src/main/java/org/ecocean/servlet/ProjectUpdate.java @@ -187,20 +187,25 @@ public void doPost(HttpServletRequest request, HttpServletResponse response) private void addOrRemoveUsersFromProject(Project project, Shepherd myShepherd, JSONArray usersToAddJSONArr, String action, JSONObject res) { + JSONArray unresolved = res.optJSONArray("unresolvedUserIds"); + if (unresolved == null) { + unresolved = new JSONArray(); + res.put("unresolvedUserIds", unresolved); + } for (int i = 0; i < usersToAddJSONArr.length(); i++) { String userId = usersToAddJSONArr.getString(i); - User user = null; - if (Util.isUUID(userId)) { - user = myShepherd.getUserByUUID(userId); - } else { - user = myShepherd.getUser(userId); - } + User user = resolveUser(myShepherd, userId); if (user != null && !StringUtils.isNullOrEmpty(action)) { if ("add".equals(action)) { project.addUser(user); } else if ("remove".equals(action)) { project.removeUser(user); } + } else { + unresolved.put(userId); + System.out.println( + "ProjectUpdate.addOrRemoveUsersFromProject: could not resolve userId=" + + userId + " for action=" + action + " project=" + project.getId()); } } myShepherd.updateDBTransaction(); @@ -208,6 +213,34 @@ private void addOrRemoveUsersFromProject(Project project, Shepherd myShepherd, res.put("success", true); } + // GH-1545: Some legacy User accounts have an email stored in their UUID primary key + // (see UserCreate.java history and the deprecated User(String uuid) constructor). + // The autocomplete therefore posts an email-shaped identifier, Util.isUUID returns + // false, and the legacy getUser(username) lookup misses when username != email. + // Fall back to an email lookup so those accounts can still be added/removed until + // a data migration repairs the affected rows. Logs every fallback hit. + static User resolveUser(Shepherd myShepherd, String userId) { + if (StringUtils.isNullOrEmpty(userId)) return null; + User user = null; + if (Util.isUUID(userId)) { + user = myShepherd.getUserByUUID(userId); + if (user == null) { + return null; + } + return user; + } + user = myShepherd.getUser(userId); + if (user != null) return user; + user = myShepherd.getUserByEmailAddress(userId); + if (user != null) { + System.out.println( + "ProjectUpdate.resolveUser: resolved userId=" + userId + + " via email-address fallback (GH-1545 legacy uuid). user.uuid=" + + user.getUUID()); + } + return user; + } + private JSONArray removeUnauthorizedEncounters(JSONArray encountersToAddJSONArr, Shepherd myShepherd, HttpServletRequest request) { JSONArray filteredResults = new JSONArray(); diff --git a/src/main/java/org/ecocean/servlet/UserCreate.java b/src/main/java/org/ecocean/servlet/UserCreate.java index 934f41d704..726e21aa15 100644 --- a/src/main/java/org/ecocean/servlet/UserCreate.java +++ b/src/main/java/org/ecocean/servlet/UserCreate.java @@ -100,7 +100,10 @@ public void doPost(HttpServletRequest request, HttpServletResponse response) salt); newUser = new User(username, hashedPassword, salt); } else { - newUser = new User(email); + // GH-1545: use the (email, uuid) ctor so the email is not stored as the UUID. + // The single-arg User(String uuid) ctor was misused here, producing + // accounts whose primary key is the email address. + newUser = new User(email, Util.generateUUID()); } myShepherd.getPM().makePersistent(newUser); createThisUser = true; From a0ca664ca81e747482aa25b42d03c9e056570a62 Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 16 Apr 2026 13:34:38 -0700 Subject: [PATCH 2/2] Try PK lookup first in resolveUser to avoid username-collision edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on #1546 flagged a real bug: a non-UUID-shaped identifier went to getUser(username) before the email fallback, so a legacy email-as-UUID account could be mis-resolved to a different user whose username happens to equal that email. Put getUserByUUID first regardless of string shape — JDO application identity does a literal PK lookup, so it resolves real UUIDs AND email-as-UUID rows correctly, without risking the wrong match. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../org/ecocean/servlet/ProjectUpdate.java | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/ecocean/servlet/ProjectUpdate.java b/src/main/java/org/ecocean/servlet/ProjectUpdate.java index 02cbc0eee0..bc4623705a 100644 --- a/src/main/java/org/ecocean/servlet/ProjectUpdate.java +++ b/src/main/java/org/ecocean/servlet/ProjectUpdate.java @@ -213,22 +213,25 @@ private void addOrRemoveUsersFromProject(Project project, Shepherd myShepherd, res.put("success", true); } - // GH-1545: Some legacy User accounts have an email stored in their UUID primary key - // (see UserCreate.java history and the deprecated User(String uuid) constructor). - // The autocomplete therefore posts an email-shaped identifier, Util.isUUID returns - // false, and the legacy getUser(username) lookup misses when username != email. - // Fall back to an email lookup so those accounts can still be added/removed until - // a data migration repairs the affected rows. Logs every fallback hit. + // GH-1545: Resolve a user from an autocomplete-supplied identifier. + // + // Ordering matters. Some legacy User accounts have an email stored in their UUID + // primary key (see UserCreate.java history and the deprecated User(String uuid) + // constructor). For those rows, the frontend posts `u.getId()` which is the email. + // + // We therefore try the PK lookup FIRST, not last — and regardless of whether the + // string looks UUID-shaped — because: + // (1) for real UUIDs it's the direct, correct path; and + // (2) for legacy email-as-UUID rows it still resolves via the literal PK + // ("alice@example.org" IS that user's primary key), which avoids a wrong + // match if some other account happens to have that email as their username. + // + // Only after the PK miss do we try username and then email-address lookups. + // Every email-fallback hit is logged so operators can gauge the migration backlog. static User resolveUser(Shepherd myShepherd, String userId) { if (StringUtils.isNullOrEmpty(userId)) return null; - User user = null; - if (Util.isUUID(userId)) { - user = myShepherd.getUserByUUID(userId); - if (user == null) { - return null; - } - return user; - } + User user = myShepherd.getUserByUUID(userId); + if (user != null) return user; user = myShepherd.getUser(userId); if (user != null) return user; user = myShepherd.getUserByEmailAddress(userId);