Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions api/src/org/labkey/api/exp/query/SamplesSchema.java
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,17 @@ public ForeignKey materialIdForeignKey(@Nullable final ExpSampleType st, @Nullab

private TableInfo createLookupTableInfo()
{
// Hack to support lookup via RowId or Name
if (domainProperty != null && domainProperty.getPropertyType().getJdbcType().isText())
_columnName = "Name";

// GitHub Issue #688
if (st != null)
return getTable(tableName, getLookupContainerFilter());

ExpMaterialTable ret = ExperimentService.get().createMaterialTable(SamplesSchema.this, getLookupContainerFilter(), st);
ret.populate();
ret.overlayMetadata(ret.getPublicName(), SamplesSchema.this, new ArrayList<>());
if (domainProperty != null && domainProperty.getPropertyType().getJdbcType().isText())
{
// Hack to support lookup via RowId or Name
_columnName = "Name";
}
ret.setLocked(true);
return ret;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.jetbrains.annotations.Nullable;
import org.labkey.api.data.CompareType;
import org.labkey.api.data.DbSchemaType;
import org.labkey.api.data.DbSchema;
import org.labkey.api.data.SimpleFilter.FilterClause;
import org.labkey.api.data.TableInfo;
import org.labkey.api.query.FieldKey;
Expand All @@ -17,6 +18,7 @@
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;

public interface DatabaseMigrationService
{
Expand Down Expand Up @@ -51,6 +53,10 @@ default void registerSchemaHandler(MigrationSchemaHandler schemaHandler) {}
default void registerTableHandler(MigrationTableHandler tableHandler) {}
default void registerMigrationFilter(MigrationFilter filter) {}

// Register a contributor that runs during migration before a schema's tables are processed.
// Useful for modules that need to register table handlers for a schema owned by another module.
default void registerSchemaContributor(String schemaName, Consumer<DbSchema> contributor) {}

default @Nullable MigrationFilter getMigrationFilter(String propertyName)
{
return null;
Expand Down
153 changes: 86 additions & 67 deletions api/src/org/labkey/api/security/Encryption.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import org.labkey.api.util.HelpTopic;
import org.labkey.api.util.HtmlStringBuilder;
import org.labkey.api.util.JobRunner;
import org.labkey.api.util.QuietCloser;
import org.labkey.api.util.StringUtilsLabKey;
import org.labkey.api.util.logging.LogHelper;
import org.labkey.api.view.ViewContext;
Expand Down Expand Up @@ -570,7 +571,10 @@ static void registerHandler(EncryptionMigrationHandler handler)
*/
public static void prepareMigrationFallback()
{
if (!isEncryptionPassPhraseSpecified())
// Skip if no pass phrase is configured (nothing to fall back to) or we're not inserting data such
// as during database migration (checkMigration() is gated the same way, so the fallback would be unused).
// The latter avoids constructing an AES instance, which would lazily create a salt row in the property store.
if (!isEncryptionPassPhraseSpecified() || !ModuleLoader.getInstance().shouldInsertData())
return;

String oldPassPhrase = getOldEncryptionPassPhrase();
Expand Down Expand Up @@ -677,103 +681,118 @@ else if (!cipher.equals(AESConfig.current.getCipherName()))
}


private static final EncryptionMigrationHandler TEST_HANDLER = (oldPassPhrase, keySource, oldConfig) -> {};
private static final EncryptionMigrationHandler TEST_HANDLER = (_, _, _) -> {};

private static QuietCloser createErrorCountResetter()
{
int initial = DECRYPTION_EXCEPTIONS.get();
return () -> DECRYPTION_EXCEPTIONS.set(initial);
}

public static class TestCase extends Assert
{
@Test
public void testEncryptionAlgorithms() throws NoSuchAlgorithmException
{
String passPhrase = "Here's my super secret pass phrase";
try (var _ = createErrorCountResetter())
{
String passPhrase = "Here's my super secret pass phrase";

Algorithm aesPassPhrase = new AES(passPhrase, 128, "test pass phrase");
Algorithm aesPassPhrase = new AES(passPhrase, 128, "test pass phrase");

test(aesPassPhrase);
test(aesPassPhrase);

if (isEncryptionPassPhraseSpecified())
{
Algorithm aes = getAES128(TEST_HANDLER);
test(aes);
if (isEncryptionPassPhraseSpecified())
{
Algorithm aes = getAES128(TEST_HANDLER);
test(aes);

// Test that static factory method matches this configuration
Algorithm aes2 = new AES(getEncryptionPassPhrase(), 128, "test pass phrase");
// Test that static factory method matches this configuration
Algorithm aes2 = new AES(getEncryptionPassPhrase(), 128, "test pass phrase");

test(aes, aes2);
test(aes2, aes);
}
test(aes, aes2);
test(aes2, aes);
}

if (Cipher.getMaxAllowedKeyLength("AES") >= 256)
{
test(new AES(passPhrase, 256, "test pass phrase"));
if (Cipher.getMaxAllowedKeyLength("AES") >= 256)
{
test(new AES(passPhrase, 256, "test pass phrase"));
}
}
}

@Test(expected = DecryptionException.class)
public void testBadKeyException()
{
String textToEncrypt = "this is some text I want to encrypt";
String passPhrase = "Here's my super secret pass phrase";
String wrongPassPhrase = passPhrase + " not";

// Our AES implementation can usually detect a bad pass phrase (based on padding anomalies), but this is not 100% guaranteed.
// Give the test three tries... by my calculations, this will fail once in every 2.6 million runs, which we can live with.
for (int i = 0; i < 3; i++)
try (var _ = createErrorCountResetter())
{
Algorithm aesPassPhrase = new AES(passPhrase, 128, "test pass phrase");
byte[] encrypted = aesPassPhrase.encrypt(textToEncrypt);
String textToEncrypt = "this is some text I want to encrypt";
String passPhrase = "Here's my super secret pass phrase";
String wrongPassPhrase = passPhrase + " not";

// Our AES implementation can usually detect a bad pass phrase (based on padding anomalies), but this is not 100% guaranteed.
// Give the test three tries... by my calculations, this will fail once in every 2.6 million runs, which we can live with.
for (int i = 0; i < 3; i++)
{
Algorithm aesPassPhrase = new AES(passPhrase, 128, "test pass phrase");
byte[] encrypted = aesPassPhrase.encrypt(textToEncrypt);

Algorithm aesWrongPassPhrase = new AES(wrongPassPhrase, 128, "test pass phrase");
aesWrongPassPhrase.decrypt(encrypted);
Algorithm aesWrongPassPhrase = new AES(wrongPassPhrase, 128, "test pass phrase");
aesWrongPassPhrase.decrypt(encrypted);
}
}
}

@Test
public void testMigrationFallback()
{
String text = "test plaintext";
AES oldAlgorithm = new AES("old pass phrase", 128, "old algorithm");
byte[] oldEncrypted = oldAlgorithm.encrypt(text);
try (var _ = createErrorCountResetter())
{
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);
// 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 1: no fallback — primary fails and counter increments
int counterBefore = DECRYPTION_EXCEPTIONS.get();
try
{
primary.decrypt(oldEncrypted);
fail("Expected DecryptionException");
}
catch (DecryptionException _) {}
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 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;
// 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 _) {}
finally
{
_migrationFallback = null;
}
assertEquals(counterBeforeWrongFallback + 1, DECRYPTION_EXCEPTIONS.get());
}
assertEquals(counterBeforeWrongFallback + 1, DECRYPTION_EXCEPTIONS.get());
}

private void test(Algorithm algorithm)
Expand Down
2 changes: 1 addition & 1 deletion pipeline/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
activemqVersion=5.19.5
activemqVersion=5.19.6

geronimoJ2eeConnector15SpecVersion=1.0.1

Expand Down
15 changes: 15 additions & 0 deletions query/src/org/labkey/query/QueryTestCase.jsp
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,21 @@ d,seven,twelve,day,month,date,duration,guid
new MethodSqlTest("SELECT CAST(AGE(CAST('02 Jan 2003' AS TIMESTAMP), CAST('03 Jan 2004' AS TIMESTAMP), SQL_TSI_YEAR) AS INTEGER)", JdbcType.INTEGER, 1),
new MethodSqlTest("SELECT CAST(AGE(CAST('02 Jan 2003' AS TIMESTAMP), CAST('01 Feb 2004' AS TIMESTAMP), SQL_TSI_MONTH) AS INTEGER)", JdbcType.INTEGER, 12),
new MethodSqlTest("SELECT CAST(AGE(CAST('02 Jan 2003' AS TIMESTAMP), CAST('02 Feb 2004' AS TIMESTAMP), SQL_TSI_MONTH) AS INTEGER)", JdbcType.INTEGER, 13),
// age_in_days() and age(..., SQL_TSI_DAY) - counts calendar-day boundaries (SQL Server semantics)
new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 395),
new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('31 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2003' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, -395),
new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2004' AS TIMESTAMP), CAST('02 Jan 2004' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 1),
new MethodSqlTest("SELECT CAST(AGE(CAST('01 Jan 2003' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, 395),
new MethodSqlTest("SELECT CAST(AGE(CAST('31 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2003' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, -395),
// age_in_days() with datetime inputs - verifies calendar-boundary counting (not 24-hour elapsed time)
new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 00:00:01' AS TIMESTAMP), CAST('01 Jan 2003 23:59:59' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 0),
new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 23:59:59' AS TIMESTAMP), CAST('02 Jan 2003 00:00:01' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 1),
new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 12:00:00' AS TIMESTAMP), CAST('02 Jan 2003 11:00:00' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 1),
new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('02 Jan 2003 00:00:01' AS TIMESTAMP), CAST('01 Jan 2003 23:59:59' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, -1),
new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 06:00:00' AS TIMESTAMP), CAST('03 Jan 2003 18:00:00' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 2),
// age(..., SQL_TSI_DAY) with datetime inputs - same calendar-boundary semantics
new MethodSqlTest("SELECT CAST(AGE(CAST('01 Jan 2003 00:00:01' AS TIMESTAMP), CAST('01 Jan 2003 23:59:59' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, 0),
new MethodSqlTest("SELECT CAST(AGE(CAST('01 Jan 2003 23:59:59' AS TIMESTAMP), CAST('02 Jan 2003 00:00:01' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, 1),
new MethodSqlTest("SELECT CAST('1' AS SQL_INTEGER) ", JdbcType.INTEGER, 1),
new MethodSqlTest("SELECT CAST('1' AS INTEGER) ", JdbcType.INTEGER, 1),
new MethodSqlTest("SELECT CAST('1.5' AS DOUBLE) ", JdbcType.DOUBLE, 1.5),
Expand Down
29 changes: 23 additions & 6 deletions query/src/org/labkey/query/controllers/GetQueryDetailsAction.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.NotNull;
import org.json.JSONArray;
import org.json.JSONObject;
import org.labkey.api.action.Action;
import org.labkey.api.action.ActionType;
import org.labkey.api.action.ApiResponse;
import org.labkey.api.action.ApiSimpleResponse;
import org.labkey.api.action.BaseViewAction;
import org.labkey.api.action.HasBindParameters;
import org.labkey.api.action.ReadOnlyApiAction;
import org.labkey.api.collections.CaseInsensitiveHashMap;
import org.labkey.api.collections.CaseInsensitiveHashSet;
Expand Down Expand Up @@ -63,6 +66,7 @@
import org.labkey.query.CustomViewUtil;
import org.labkey.query.QueryDefinitionImpl;
import org.labkey.query.persist.QueryDef;
import org.springframework.beans.PropertyValues;
import org.springframework.validation.BindException;

import java.util.ArrayList;
Expand Down Expand Up @@ -460,7 +464,7 @@ protected List<Map<String,Object>> getDefViewColProps(QueryView view)
return colProps;
}

public static class Form
public static class Form implements HasBindParameters
{
private String _queryName;
private String _schemaName;
Expand Down Expand Up @@ -496,11 +500,6 @@ public String[] getViewName()
return _viewName;
}

public void setViewName(String[] viewName)
{
_viewName = viewName;
}

public String getFk()
{
return _fk;
Expand Down Expand Up @@ -550,5 +549,23 @@ public void setIncludeTriggers(boolean includeTriggers)
{
_includeTriggers = includeTriggers;
}

@Override
public @NotNull BindException bindParameters(PropertyValues params)
{
// GitHub Issue #936 : manually bind the viewName parameter
var viewName = params.getPropertyValue("viewName");
if (viewName != null)
{
var value = viewName.getValue();
if (value instanceof String[] strs)
_viewName = strs;
else if (value instanceof String str)
_viewName = new String[] { str };
else
LOG.error("Unexpected viewName parameter type: " + value);
}
return BaseViewAction.springBindParameters(this, "form", params);
}
}
}
Loading
Loading