Skip to content

Commit a2035ad

Browse files
committed
Merge remote-tracking branch 'origin/develop' into fb_restrict_inactive
2 parents 9976e9b + 565150c commit a2035ad

13 files changed

Lines changed: 183 additions & 57 deletions

File tree

api/src/org/labkey/api/data/ConditionalFormat.java

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,42 @@ public String getCssStyle()
188188
return sb.toString();
189189
}
190190

191+
/**
192+
* Converts the XML filter entries for a single conditional format into a URL query string suitable for
193+
* use as a {@link GWTConditionalFormat} filter value.
194+
*/
195+
@Nullable
196+
public static String buildFilterQueryString(@Nullable ConditionalFormatFiltersType filters)
197+
{
198+
SimpleFilter simpleFilter = new SimpleFilter();
199+
if (null != filters)
200+
{
201+
ConditionalFormatFilterType[] filterArray = filters.getFilterArray();
202+
if (filterArray != null)
203+
{
204+
for (ConditionalFormatFilterType filter : filterArray)
205+
{
206+
CompareType compareType = CompareType.getByURLKey(filter.getOperator().toString());
207+
if (compareType != null)
208+
simpleFilter.addClause(compareType.createFilterClause(FieldKey.fromParts(COLUMN_NAME), filter.getValue()));
209+
else
210+
LOG.warn("Could not find CompareType for " + filter.getOperator() + ", ignoring");
211+
}
212+
}
213+
}
214+
try
215+
{
216+
// Process it through a URL to get the query string equivalent
217+
URLHelper url = new URLHelper("/test");
218+
simpleFilter.applyToURL(url, DATA_REGION_NAME);
219+
return url.getQueryString();
220+
}
221+
catch (URISyntaxException e)
222+
{
223+
throw UnexpectedException.wrap(e);
224+
}
225+
}
226+
191227
/** Converts from the XMLBean representation to our standard class. Does not save to the database */
192228
@NotNull
193229
public static List<ConditionalFormat> convertFromXML(ConditionalFormatsType conditionalFormats)
@@ -200,38 +236,7 @@ public static List<ConditionalFormat> convertFromXML(ConditionalFormatsType cond
200236
for (ConditionalFormatType xmlFormat : conditionalFormats.getConditionalFormatArray())
201237
{
202238
ConditionalFormat format = new ConditionalFormat();
203-
SimpleFilter simpleFilter = new SimpleFilter();
204-
ConditionalFormatFiltersType filters = xmlFormat.getFilters();
205-
if (null != filters)
206-
{
207-
ConditionalFormatFilterType[] filterArray = filters.getFilterArray();
208-
if (filterArray != null)
209-
{
210-
for (ConditionalFormatFilterType filter : filterArray)
211-
{
212-
CompareType compareType = CompareType.getByURLKey(filter.getOperator().toString());
213-
if (compareType != null)
214-
{
215-
simpleFilter.addClause(compareType.createFilterClause(FieldKey.fromParts(COLUMN_NAME), filter.getValue()));
216-
}
217-
else
218-
{
219-
LOG.warn("Could not find CompareType for " + filter.getOperator() + ", ignoring");
220-
}
221-
}
222-
}
223-
}
224-
try
225-
{
226-
// Process it through a URL to get the query string equivalent
227-
URLHelper url = new URLHelper("/test");
228-
simpleFilter.applyToURL(url, DATA_REGION_NAME);
229-
format.setFilter(url.getQueryString());
230-
}
231-
catch (URISyntaxException e)
232-
{
233-
throw UnexpectedException.wrap(e);
234-
}
239+
format.setFilter(buildFilterQueryString(xmlFormat.getFilters()));
235240
if (xmlFormat.isSetBold() && xmlFormat.getBold())
236241
{
237242
format.setBold(true);

api/src/org/labkey/api/exp/property/DomainUtil.java

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@
4848
import org.labkey.api.data.TableInfo;
4949
import org.labkey.api.data.TableInfo.IndexDefinition;
5050
import org.labkey.api.data.TableSelector;
51-
import org.labkey.api.dataiterator.DataIteratorUtil;
5251
import org.labkey.api.defaults.DefaultValueService;
5352
import org.labkey.api.exp.ChangePropertyDescriptorException;
5453
import org.labkey.api.exp.DomainDescriptor;
@@ -85,8 +84,6 @@
8584
import org.labkey.api.query.UserSchema;
8685
import org.labkey.api.query.ValidationException;
8786
import org.labkey.api.security.User;
88-
import org.labkey.api.settings.AppProps;
89-
import org.labkey.api.settings.OptionalFeatureService;
9087
import org.labkey.api.util.DateUtil;
9188
import org.labkey.api.util.GUID;
9289
import org.labkey.api.util.JdbcUtil;
@@ -97,7 +94,6 @@
9794
import org.labkey.api.util.StringUtilsLabKey;
9895
import org.labkey.api.view.UnauthorizedException;
9996
import org.labkey.data.xml.ColumnType;
100-
import org.labkey.data.xml.ConditionalFormatFilterType;
10197
import org.labkey.data.xml.ConditionalFormatType;
10298
import org.labkey.data.xml.TableType;
10399

@@ -444,8 +440,6 @@ public static boolean allowMultiChoice(DomainKind<?> kind)
444440
{
445441
if (!kind.allowMultiChoiceProperties())
446442
return false;
447-
if (!OptionalFeatureService.get().isFeatureEnabled(AppProps.MULTI_VALUE_TEXT_CHOICE))
448-
return false;
449443
return CoreSchema.getInstance().getSqlDialect().isPostgreSQL();
450444
}
451445

@@ -661,9 +655,7 @@ public static GWTPropertyDescriptor getPropertyDescriptor(ColumnType columnXml)
661655
gwtFormat.setStrikethrough(formatType.getStrikethrough());
662656
gwtFormat.setTextColor(formatType.getTextColor());
663657
gwtFormat.setBackgroundColor(formatType.getBackgroundColor());
664-
for (ConditionalFormatFilterType filterType : formatType.getFilters().getFilterArray())
665-
gwtFormat.setFilter("format.column%7E" + filterType.getOperator().toString() + "=" + filterType.getValue());
666-
658+
gwtFormat.setFilter(ConditionalFormat.buildFilterQueryString(formatType.getFilters())); // GitHub Issue #1056
667659
formats.add(gwtFormat);
668660
}
669661
gwtProp.setConditionalFormats(formats);

api/src/org/labkey/api/inventory/InventoryService.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ enum InventoryStatusColumn
5252
StorageColSort,
5353
StorageComment,
5454
StorageLocation,
55+
StorageTerminalLocation,
5556
StorageRow,
5657
StorageRowSort,
5758
StoragePositionNumber,

api/src/org/labkey/api/security/Encryption.java

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959

6060
import javax.crypto.BadPaddingException;
6161
import javax.crypto.Cipher;
62+
import javax.crypto.IllegalBlockSizeException;
6263
import javax.crypto.SecretKey;
6364
import javax.crypto.SecretKeyFactory;
6465
import javax.crypto.spec.GCMParameterSpec;
@@ -504,14 +505,32 @@ public String decrypt(byte @NotNull[] cipherText)
504505
cipher.init(Cipher.DECRYPT_MODE, _keySpec, _config.createIvSpec(iv));
505506
return new String(cipher.doFinal(encrypted), StringUtilsLabKey.DEFAULT_CHARSET);
506507
}
507-
catch (BadPaddingException e)
508+
catch (BadPaddingException | IllegalBlockSizeException e)
508509
{
509-
// For now, assume that BadPaddingException means the key has been changed and all other
510-
// exceptions are coding issues. That might change in the future...
510+
// Decryption failure - likely a bad key or old algorithm.
511511

512-
// Track all decryption exceptions that aren't caused by TestCase (below)
512+
// Track all decryption exceptions that aren't caused by TestCase.
513+
// Only the production AES instance (ENCRYPTION_KEY_CHANGED keySource) attempts the fallback;
514+
// migration-temporary instances bypass this block entirely.
513515
if (ENCRYPTION_KEY_CHANGED.equals(_keySource))
516+
{
517+
// During migration, not-yet-migrated values are in the old format. If a fallback algorithm
518+
// is registered (set by prepareMigrationFallback() when migration is known to be incomplete),
519+
// try it before giving up.
520+
Algorithm fallback = _migrationFallback;
521+
if (fallback != null)
522+
{
523+
try
524+
{
525+
return fallback.decrypt(cipherText);
526+
}
527+
catch (RuntimeException ignored)
528+
{
529+
// Both algorithms failed; fall through to increment and rethrow
530+
}
531+
}
514532
DECRYPTION_EXCEPTIONS.incrementAndGet();
533+
}
515534

516535
throw new DecryptionException("Could not decrypt this content using the " + _keySource, e);
517536
}
@@ -524,6 +543,9 @@ public String decrypt(byte @NotNull[] cipherText)
524543

525544
private static final String ENCRYPTION_KEY_CHANGED = "currently configured EncryptionKey; has the key changed in " + AppProps.getInstance().getWebappConfigurationFilename() + "?";
526545
private static final AtomicInteger DECRYPTION_EXCEPTIONS = new AtomicInteger(0);
546+
// Set by prepareMigrationFallback() when migration is known to be incomplete; cleared after migration completes.
547+
// Allows HTTP requests to decrypt not-yet-migrated values without failing.
548+
private static volatile Algorithm _migrationFallback = null;
527549

528550
public static class DecryptionException extends ConfigurationException
529551
{
@@ -574,6 +596,39 @@ static void registerHandler(EncryptionMigrationHandler handler)
574596
void deleteEncryptedContent();
575597
}
576598

599+
/**
600+
* Examines the database to determine whether algorithm or key migration is pending, and if so installs a
601+
* fallback algorithm. This allows HTTP requests to transparently decrypt not-yet-migrated values during the
602+
* migration window instead of failing and incrementing DECRYPTION_EXCEPTIONS.
603+
* Must be called after the database and PropertyManager are available (e.g., from CoreModule.afterUpdate()).
604+
* The fallback is cleared automatically once checkMigration() confirms completion.
605+
*/
606+
public static void prepareMigrationFallback()
607+
{
608+
if (!isEncryptionPassPhraseSpecified())
609+
return;
610+
611+
String oldPassPhrase = getOldEncryptionPassPhrase();
612+
613+
String cipher = PropertyManager.getNormalStore()
614+
.getProperties(ENCRYPTION_CIPHER_CATEGORY)
615+
.get(CIPHER_PROPERTY);
616+
617+
if (oldPassPhrase != null)
618+
{
619+
// Key-change migration not yet complete; use old key. If cipher is also null, old content used the
620+
// legacy cipher (matching what checkMigration() will use: old key + AESConfig.legacy); otherwise
621+
// old content used the current cipher.
622+
AESConfig fallbackConfig = cipher == null ? AESConfig.legacy : AESConfig.current;
623+
_migrationFallback = new AES(oldPassPhrase, 128, "legacy key migration fallback", fallbackConfig);
624+
}
625+
else if (cipher == null)
626+
{
627+
// Cipher migration not yet complete; fall back to legacy cipher with current key
628+
_migrationFallback = new AES(getEncryptionPassPhrase(), 128, "legacy cipher migration fallback", AESConfig.legacy);
629+
}
630+
}
631+
577632
public static void checkMigration()
578633
{
579634
String oldPassPhrase = getOldEncryptionPassPhrase();
@@ -582,6 +637,7 @@ public static void checkMigration()
582637
if (isEncryptionPassPhraseSpecified() && ModuleLoader.getInstance().shouldInsertData())
583638
{
584639
boolean migrationNeeded = false;
640+
boolean migrationSucceeded = false;
585641
String keySource = null;
586642

587643
if (null != oldPassPhrase)
@@ -626,11 +682,16 @@ else if (!cipher.equals(AESConfig.current.getCipherName()))
626682

627683
CacheManager.clearAllKnownCaches();
628684
}
629-
// Test to validate conversion and create a validation value if needed
685+
// Test to validate conversion and create a validation value if needed.
686+
// Capture the counter before the test so the save decision is based solely on whether
687+
// this specific test passes, not on concurrent HTTP request decryption failures that may
688+
// have incremented the counter during the (potentially long) migration of auth configurations.
689+
int exceptionsBeforeFinalTest = DECRYPTION_EXCEPTIONS.get();
630690
testEncryptionKey();
691+
migrationSucceeded = DECRYPTION_EXCEPTIONS.get() == exceptionsBeforeFinalTest;
631692
}
632693

633-
if (DECRYPTION_EXCEPTIONS.get() == 0)
694+
if (migrationSucceeded)
634695
{
635696
if (oldPassPhrase != null)
636697
{
@@ -643,8 +704,11 @@ else if (!cipher.equals(AESConfig.current.getCipherName()))
643704
cipherProps.save();
644705
LOG.info("Migration from existing encrypted content from legacy AES configuration to current AES configuration is complete.");
645706
}
707+
DECRYPTION_EXCEPTIONS.set(0);
646708
}
647709
}
710+
711+
_migrationFallback = null;
648712
}
649713

650714
public static void deleteEncryptedContent(User user)
@@ -745,6 +809,55 @@ public void testBadKeyException()
745809
}
746810
}
747811

812+
@Test
813+
public void testMigrationFallback()
814+
{
815+
String text = "test plaintext";
816+
AES oldAlgorithm = new AES("old pass phrase", 128, "old algorithm");
817+
byte[] oldEncrypted = oldAlgorithm.encrypt(text);
818+
819+
// Primary (production) instance: different pass phrase, keySource == ENCRYPTION_KEY_CHANGED
820+
AES primary = new AES("primary pass phrase", 128, ENCRYPTION_KEY_CHANGED);
821+
822+
// Case 1: no fallback — primary fails and counter increments
823+
int counterBefore = DECRYPTION_EXCEPTIONS.get();
824+
try
825+
{
826+
primary.decrypt(oldEncrypted);
827+
fail("Expected DecryptionException");
828+
}
829+
catch (DecryptionException ignored) {}
830+
assertEquals(counterBefore + 1, DECRYPTION_EXCEPTIONS.get());
831+
832+
// Case 2: correct fallback — transparent success, counter unchanged
833+
_migrationFallback = oldAlgorithm;
834+
try
835+
{
836+
int counterBeforeFallback = DECRYPTION_EXCEPTIONS.get();
837+
assertEquals(text, primary.decrypt(oldEncrypted));
838+
assertEquals("Counter must not increment when fallback succeeds", counterBeforeFallback, DECRYPTION_EXCEPTIONS.get());
839+
}
840+
finally
841+
{
842+
_migrationFallback = null;
843+
}
844+
845+
// Case 3: wrong fallback — both algorithms fail, counter increments
846+
_migrationFallback = new AES("wrong pass phrase", 128, "wrong fallback");
847+
int counterBeforeWrongFallback = DECRYPTION_EXCEPTIONS.get();
848+
try
849+
{
850+
primary.decrypt(oldEncrypted);
851+
fail("Expected DecryptionException");
852+
}
853+
catch (DecryptionException ignored) {}
854+
finally
855+
{
856+
_migrationFallback = null;
857+
}
858+
assertEquals(counterBeforeWrongFallback + 1, DECRYPTION_EXCEPTIONS.get());
859+
}
860+
748861
private void test(Algorithm algorithm)
749862
{
750863
test(algorithm, algorithm);

api/src/org/labkey/api/security/SecurityManager.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import org.apache.commons.collections4.MultiValuedMap;
2929
import org.apache.commons.collections4.multimap.HashSetValuedHashMap;
3030
import org.apache.commons.lang3.StringUtils;
31+
import org.apache.commons.lang3.Strings;
3132
import org.apache.logging.log4j.Level;
3233
import org.apache.logging.log4j.Logger;
3334
import org.jetbrains.annotations.NotNull;
@@ -662,6 +663,13 @@ public static Pair<User, HttpServletRequest> attemptAuthentication(HttpServletRe
662663
// Passing via the "apikey" HTTP header is our preferred approach and used by most
663664
// LabKey client API implementations
664665
String apiKey = request.getHeader(API_KEY);
666+
667+
if (null == apiKey)
668+
{
669+
String authorization = request.getHeader("Authorization");
670+
if (Strings.CI.startsWith(authorization, "Bearer "))
671+
apiKey = StringUtils.trimToNull(authorization.substring("Bearer ".length()));
672+
}
665673

666674
if (null == apiKey)
667675
{

api/src/org/labkey/api/security/User.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import org.labkey.api.security.permissions.SampleWorkflowJobPermission;
4343
import org.labkey.api.security.permissions.SeeGroupDetailsPermission;
4444
import org.labkey.api.security.permissions.SiteAdminPermission;
45+
import org.labkey.api.security.permissions.TroubleshooterPermission;
4546
import org.labkey.api.security.permissions.TrustedPermission;
4647
import org.labkey.api.security.permissions.UpdatePermission;
4748
import org.labkey.api.security.roles.AbstractRootContainerRole;
@@ -312,6 +313,11 @@ public boolean isTrustedBrowserDev()
312313
return hasRootPermissions(TRUSTED_BROWSER_DEV);
313314
}
314315

316+
public boolean isTroubleshooter()
317+
{
318+
return hasRootPermission(TroubleshooterPermission.class);
319+
}
320+
315321
public boolean isBrowserDev()
316322
{
317323
return hasRootPermission(BrowserDeveloperPermission.class);

api/src/org/labkey/api/settings/AppProps.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ public interface AppProps
5050
String ADMIN_PROVIDED_ALLOWED_EXTERNAL_RESOURCES = "allowedExternalResources";
5151
String QUANTITY_COLUMN_SUFFIX_TESTING = "quantityColumnSuffixTesting";
5252
String REJECT_CONTROLLER_FIRST_URLS = "rejectControllerFirstUrls";
53-
String MULTI_VALUE_TEXT_CHOICE = "multiChoiceDataType";
5453

5554
String UNKNOWN_VERSION = "Unknown Release Version";
5655

api/src/org/labkey/api/view/PopupAdminView.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public static NavTree createNavTree(final ViewContext context)
101101
}
102102
}
103103

104-
if (user.isAnalyst() || user.hasRootPermission(TroubleshooterPermission.class))
104+
if (user.isAnalyst() || user.isTroubleshooter())
105105
{
106106
NavTree devMenu = new NavTree("Developer Links");
107107
devMenu.addChildren(DeveloperMenu.getNavTree(context));

core/src/org/labkey/core/CoreModule.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,11 @@ public void afterUpdate(ModuleContext moduleContext)
869869
ContainerManager.getHomeContainer();
870870
}
871871
});
872+
873+
// Install a fallback decryption algorithm if AES migration is pending. This prevents concurrent HTTP requests
874+
// from failing to decrypt not-yet-migrated values during the migration window. Called here (afterUpdate) rather
875+
// than in startupAfterSpringConfig so the fallback is active before any long-running upgrade steps run.
876+
Encryption.prepareMigrationFallback();
872877
}
873878

874879
private void bootstrap()

0 commit comments

Comments
 (0)