From 5beea20633aa54c04702737ff863d6a9f35f4f1b Mon Sep 17 00:00:00 2001 From: Susan Hert Date: Tue, 14 Apr 2026 08:04:27 -0700 Subject: [PATCH 1/5] Update to activemqVersion to 5.19.5 (#7580) --- pipeline/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipeline/gradle.properties b/pipeline/gradle.properties index 7960fca9127..bdf43c99a8c 100644 --- a/pipeline/gradle.properties +++ b/pipeline/gradle.properties @@ -1,4 +1,4 @@ -activemqVersion=5.19.2 +activemqVersion=5.19.5 geronimoJ2eeConnector15SpecVersion=1.0.1 From d57958fd4fc7c28caa125b997e0e20278781cdac Mon Sep 17 00:00:00 2001 From: labkey-matthewb Date: Tue, 21 Apr 2026 15:24:36 -0700 Subject: [PATCH 2/5] Authorization: Bearer (#7609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Rationale Support this RFC6750 standard in addition to our "apikey" header https://github.com/LabKey/internal-issues/issues/1079 #### Related Pull Requests - #### Changes - --- api/src/org/labkey/api/security/SecurityManager.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api/src/org/labkey/api/security/SecurityManager.java b/api/src/org/labkey/api/security/SecurityManager.java index e6ccfc41323..139a7ce4545 100644 --- a/api/src/org/labkey/api/security/SecurityManager.java +++ b/api/src/org/labkey/api/security/SecurityManager.java @@ -28,6 +28,7 @@ import org.apache.commons.collections4.MultiValuedMap; import org.apache.commons.collections4.multimap.HashSetValuedHashMap; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; @@ -662,6 +663,13 @@ public static Pair attemptAuthentication(HttpServletRe // Passing via the "apikey" HTTP header is our preferred approach and used by most // LabKey client API implementations String apiKey = request.getHeader(API_KEY); + + if (null == apiKey) + { + String authorization = request.getHeader("Authorization"); + if (Strings.CI.startsWith(authorization, "Bearer ")) + apiKey = StringUtils.trimToNull(authorization.substring("Bearer ".length())); + } if (null == apiKey) { From 3a97e9e060ff499c7d78a1a1b352086c73120bbf Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Tue, 21 Apr 2026 16:04:20 -0700 Subject: [PATCH 3/5] GitHub Issue 1064: Exceptions during encryption migration prevent upgrade (#7589) #### Rationale Unfortunately timed HTTP requests can cause an upgrade to fail to properly migration encrypted properties. #### Changes - Fallback to previous encryption settings (cipher and key) prior to migration - Be sure to keep the stored cipher info in sync with the true database state --- .../org/labkey/api/security/Encryption.java | 125 +++++++++++++++++- core/src/org/labkey/core/CoreModule.java | 5 + 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/api/src/org/labkey/api/security/Encryption.java b/api/src/org/labkey/api/security/Encryption.java index 484db473869..e15144dfd09 100644 --- a/api/src/org/labkey/api/security/Encryption.java +++ b/api/src/org/labkey/api/security/Encryption.java @@ -50,6 +50,7 @@ import javax.crypto.BadPaddingException; import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; import javax.crypto.SecretKey; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.GCMParameterSpec; @@ -473,14 +474,32 @@ public String decrypt(byte @NotNull[] cipherText) cipher.init(Cipher.DECRYPT_MODE, _keySpec, _config.createIvSpec(iv)); return new String(cipher.doFinal(encrypted), StringUtilsLabKey.DEFAULT_CHARSET); } - catch (BadPaddingException e) + catch (BadPaddingException | IllegalBlockSizeException e) { - // For now, assume that BadPaddingException means the key has been changed and all other - // exceptions are coding issues. That might change in the future... + // Decryption failure - likely a bad key or old algorithm. - // Track all decryption exceptions that aren't caused by TestCase (below) + // Track all decryption exceptions that aren't caused by TestCase. + // Only the production AES instance (ENCRYPTION_KEY_CHANGED keySource) attempts the fallback; + // migration-temporary instances bypass this block entirely. if (ENCRYPTION_KEY_CHANGED.equals(_keySource)) + { + // During migration, not-yet-migrated values are in the old format. If a fallback algorithm + // is registered (set by prepareMigrationFallback() when migration is known to be incomplete), + // try it before giving up. + Algorithm fallback = _migrationFallback; + if (fallback != null) + { + try + { + return fallback.decrypt(cipherText); + } + catch (RuntimeException ignored) + { + // Both algorithms failed; fall through to increment and rethrow + } + } DECRYPTION_EXCEPTIONS.incrementAndGet(); + } throw new DecryptionException("Could not decrypt this content using the " + _keySource, e); } @@ -493,6 +512,9 @@ public String decrypt(byte @NotNull[] cipherText) private static final String ENCRYPTION_KEY_CHANGED = "currently configured EncryptionKey; has the key changed in " + AppProps.getInstance().getWebappConfigurationFilename() + "?"; private static final AtomicInteger DECRYPTION_EXCEPTIONS = new AtomicInteger(0); + // Set by prepareMigrationFallback() when migration is known to be incomplete; cleared after migration completes. + // Allows HTTP requests to decrypt not-yet-migrated values without failing. + private static volatile Algorithm _migrationFallback = null; public static class DecryptionException extends ConfigurationException { @@ -539,6 +561,39 @@ static void registerHandler(EncryptionMigrationHandler handler) void migrateEncryptedContent(String oldPassPhrase, String keySource, AESConfig oldConfig); } + /** + * Examines the database to determine whether algorithm or key migration is pending, and if so installs a + * fallback algorithm. This allows HTTP requests to transparently decrypt not-yet-migrated values during the + * migration window instead of failing and incrementing DECRYPTION_EXCEPTIONS. + * Must be called after the database and PropertyManager are available (e.g., from CoreModule.afterUpdate()). + * The fallback is cleared automatically once checkMigration() confirms completion. + */ + public static void prepareMigrationFallback() + { + if (!isEncryptionPassPhraseSpecified()) + return; + + String oldPassPhrase = getOldEncryptionPassPhrase(); + + String cipher = PropertyManager.getNormalStore() + .getProperties(ENCRYPTION_CIPHER_CATEGORY) + .get(CIPHER_PROPERTY); + + if (oldPassPhrase != null) + { + // Key-change migration not yet complete; use old key. If cipher is also null, old content used the + // legacy cipher (matching what checkMigration() will use: old key + AESConfig.legacy); otherwise + // old content used the current cipher. + AESConfig fallbackConfig = cipher == null ? AESConfig.legacy : AESConfig.current; + _migrationFallback = new AES(oldPassPhrase, 128, "legacy key migration fallback", fallbackConfig); + } + else if (cipher == null) + { + // Cipher migration not yet complete; fall back to legacy cipher with current key + _migrationFallback = new AES(getEncryptionPassPhrase(), 128, "legacy cipher migration fallback", AESConfig.legacy); + } + } + public static void checkMigration() { String oldPassPhrase = getOldEncryptionPassPhrase(); @@ -547,6 +602,7 @@ public static void checkMigration() if (isEncryptionPassPhraseSpecified() && ModuleLoader.getInstance().shouldInsertData()) { boolean migrationNeeded = false; + boolean migrationSucceeded = false; String keySource = null; if (null != oldPassPhrase) @@ -591,11 +647,16 @@ else if (!cipher.equals(AESConfig.current.getCipherName())) CacheManager.clearAllKnownCaches(); } - // Test to validate conversion and create a validation value if needed + // Test to validate conversion and create a validation value if needed. + // Capture the counter before the test so the save decision is based solely on whether + // this specific test passes, not on concurrent HTTP request decryption failures that may + // have incremented the counter during the (potentially long) migration of auth configurations. + int exceptionsBeforeFinalTest = DECRYPTION_EXCEPTIONS.get(); testEncryptionKey(); + migrationSucceeded = DECRYPTION_EXCEPTIONS.get() == exceptionsBeforeFinalTest; } - if (DECRYPTION_EXCEPTIONS.get() == 0) + if (migrationSucceeded) { if (oldPassPhrase != null) { @@ -608,8 +669,11 @@ else if (!cipher.equals(AESConfig.current.getCipherName())) cipherProps.save(); LOG.info("Migration from existing encrypted content from legacy AES configuration to current AES configuration is complete."); } + DECRYPTION_EXCEPTIONS.set(0); } } + + _migrationFallback = null; } @@ -663,6 +727,55 @@ public void testBadKeyException() } } + @Test + public void testMigrationFallback() + { + String text = "test plaintext"; + AES oldAlgorithm = new AES("old pass phrase", 128, "old algorithm"); + byte[] oldEncrypted = oldAlgorithm.encrypt(text); + + // Primary (production) instance: different pass phrase, keySource == ENCRYPTION_KEY_CHANGED + AES primary = new AES("primary pass phrase", 128, ENCRYPTION_KEY_CHANGED); + + // Case 1: no fallback — primary fails and counter increments + int counterBefore = DECRYPTION_EXCEPTIONS.get(); + try + { + primary.decrypt(oldEncrypted); + fail("Expected DecryptionException"); + } + catch (DecryptionException ignored) {} + assertEquals(counterBefore + 1, DECRYPTION_EXCEPTIONS.get()); + + // Case 2: correct fallback — transparent success, counter unchanged + _migrationFallback = oldAlgorithm; + try + { + int counterBeforeFallback = DECRYPTION_EXCEPTIONS.get(); + assertEquals(text, primary.decrypt(oldEncrypted)); + assertEquals("Counter must not increment when fallback succeeds", counterBeforeFallback, DECRYPTION_EXCEPTIONS.get()); + } + finally + { + _migrationFallback = null; + } + + // Case 3: wrong fallback — both algorithms fail, counter increments + _migrationFallback = new AES("wrong pass phrase", 128, "wrong fallback"); + int counterBeforeWrongFallback = DECRYPTION_EXCEPTIONS.get(); + try + { + primary.decrypt(oldEncrypted); + fail("Expected DecryptionException"); + } + catch (DecryptionException ignored) {} + finally + { + _migrationFallback = null; + } + assertEquals(counterBeforeWrongFallback + 1, DECRYPTION_EXCEPTIONS.get()); + } + private void test(Algorithm algorithm) { test(algorithm, algorithm); diff --git a/core/src/org/labkey/core/CoreModule.java b/core/src/org/labkey/core/CoreModule.java index fd2fd568258..23bb96e6f97 100644 --- a/core/src/org/labkey/core/CoreModule.java +++ b/core/src/org/labkey/core/CoreModule.java @@ -869,6 +869,11 @@ public void afterUpdate(ModuleContext moduleContext) ContainerManager.getHomeContainer(); } }); + + // Install a fallback decryption algorithm if AES migration is pending. This prevents concurrent HTTP requests + // from failing to decrypt not-yet-migrated values during the migration window. Called here (afterUpdate) rather + // than in startupAfterSpringConfig so the fallback is active before any long-running upgrade steps run. + Encryption.prepareMigrationFallback(); } private void bootstrap() From 7aecdd121af7b126faebab17b26965eaf55a6897 Mon Sep 17 00:00:00 2001 From: Susan Hert Date: Wed, 22 Apr 2026 14:45:47 -0700 Subject: [PATCH 4/5] Issue 1035: Add terminal storage location name column to sample grids (#7607) --- api/src/org/labkey/api/inventory/InventoryService.java | 1 + .../src/org/labkey/experiment/api/ExpMaterialTableImpl.java | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/api/src/org/labkey/api/inventory/InventoryService.java b/api/src/org/labkey/api/inventory/InventoryService.java index 7690e2c8993..4ce70bafb33 100644 --- a/api/src/org/labkey/api/inventory/InventoryService.java +++ b/api/src/org/labkey/api/inventory/InventoryService.java @@ -52,6 +52,7 @@ enum InventoryStatusColumn StorageColSort, StorageComment, StorageLocation, + StorageTerminalLocation, StorageRow, StorageRowSort, StoragePositionNumber, diff --git a/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java b/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java index 33b412786e1..f403d9e31d0 100644 --- a/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java +++ b/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java @@ -875,7 +875,11 @@ protected void populateColumns() addColumn(RawAliquotUnit).getRenderer().addQueryFieldKeys(new HashSet<>(Set.of(AliquotUnit.fieldKey()))); if (InventoryService.get() != null && (st == null || !st.isMedia())) - defaultCols.addAll(InventoryService.get().addInventoryStatusColumns(st == null ? null : st.getMetricUnit(), this, getContainer(), _userSchema.getUser())); + { + List inventoryCols = InventoryService.get().addInventoryStatusColumns(st == null ? null : st.getMetricUnit(), this, getContainer(), _userSchema.getUser()); + // GH Issue 1035: Don't include StorageTerminalLocation in the default view + defaultCols.addAll(inventoryCols.stream().filter(fk -> !fk.equals(InventoryService.InventoryStatusColumn.StorageTerminalLocation.fieldKey())).toList()); + } SQLFragment sql; UserSchema plateUserSchema; From 54fb5501891cac010e17c2153b1b11c2fccd6bfe Mon Sep 17 00:00:00 2001 From: Cory Nathe Date: Fri, 24 Apr 2026 11:47:11 -0500 Subject: [PATCH 5/5] GitHub Issue #1087: App Admins cannot view the Schema Browser link from the apps (#7620) - Add User helper for isTroubleShooter() for root permission check --- api/src/org/labkey/api/security/User.java | 6 ++++++ api/src/org/labkey/api/view/PopupAdminView.java | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/api/src/org/labkey/api/security/User.java b/api/src/org/labkey/api/security/User.java index edc32e8e2ae..5a11e99787c 100644 --- a/api/src/org/labkey/api/security/User.java +++ b/api/src/org/labkey/api/security/User.java @@ -42,6 +42,7 @@ import org.labkey.api.security.permissions.SampleWorkflowJobPermission; import org.labkey.api.security.permissions.SeeGroupDetailsPermission; import org.labkey.api.security.permissions.SiteAdminPermission; +import org.labkey.api.security.permissions.TroubleshooterPermission; import org.labkey.api.security.permissions.TrustedPermission; import org.labkey.api.security.permissions.UpdatePermission; import org.labkey.api.security.roles.AbstractRootContainerRole; @@ -312,6 +313,11 @@ public boolean isTrustedBrowserDev() return hasRootPermissions(TRUSTED_BROWSER_DEV); } + public boolean isTroubleshooter() + { + return hasRootPermission(TroubleshooterPermission.class); + } + public boolean isBrowserDev() { return hasRootPermission(BrowserDeveloperPermission.class); diff --git a/api/src/org/labkey/api/view/PopupAdminView.java b/api/src/org/labkey/api/view/PopupAdminView.java index 38a7a6b6408..3fa62d1178e 100644 --- a/api/src/org/labkey/api/view/PopupAdminView.java +++ b/api/src/org/labkey/api/view/PopupAdminView.java @@ -101,7 +101,7 @@ public static NavTree createNavTree(final ViewContext context) } } - if (user.isAnalyst() || user.hasRootPermission(TroubleshooterPermission.class)) + if (user.isAnalyst() || user.isTroubleshooter()) { NavTree devMenu = new NavTree("Developer Links"); devMenu.addChildren(DeveloperMenu.getNavTree(context));