Files
qbit-secured-data/src/test/java/com/kingsrook/qbits/secureddata/SecuredDataQBitProducerTest.java
T
james.maesandClaude Opus 5.5 0963b45467 feat: classification, policy, tier resolver and access log (phase b)
Tables secured_data_classification, _policy, _reveal_grant, _break_glass
and _access_log; SecuredDataClassifications; the tier resolver with
break-glass, table features, policy rows, channel caps and code defaults;
the asynchronous access log; enumeration guardrails (rate limit, miss and
distinct-value alerts, minimum entropy). Builds on QQQ RC.5 with marked
stand-ins for the unreleased fieldaccess API and a reflective bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:03:57 -05:00

226 lines
13 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.secureddata.metadata.FieldsOfTablePossibleValueSource;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataEnums;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qbits.secureddata.standin.ClientChannelsMetaData;
import com.kingsrook.qbits.secureddata.standin.DataClassification;
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
import com.kingsrook.qbits.secureddata.standin.TableFeaturePermissionsMetaData;
import com.kingsrook.qbits.secureddata.support.CoreMetadataBridge;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.NOW;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.RUNTIMES;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.base;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.channelUser;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.classification;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.close;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.delete;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.insert;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.logs;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.person;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.policyRow;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.setup;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.update;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.user;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Secured data qbit producer test.
*******************************************************************************/
class SecuredDataQBitProducerTest
{
/*******************************************************************************
** Cleanup.
*******************************************************************************/
@AfterEach
void cleanup()
{
close();
}
/*******************************************************************************
** Produces complete metadata.
*******************************************************************************/
@Test
void producesCompleteMetadata() throws Exception
{
QInstance instance = setup();
assertThat(instance.getTables().keySet()).containsAll(SecuredDataTables.ALL);
assertThat(instance.getQBits().get("com.kingsrook.qbits:qbit-secured-data").getVersion()).isEqualTo("1.0.0");
for(String name : SecuredDataTables.ALL)
{
QTableMetaData table = instance.getTable(name);
assertThat(table.getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
assertThat(table.getSections()).isNotEmpty();
assertThat(CoreMetadataBridge.classification(table, null)).isEqualTo(DataClassification.CONFIDENTIAL);
table.getFields().values().forEach(field -> assertThat(field.getBackendName()).isEqualTo(field.getName()));
}
assertThat(SecuredDataTables.esbTableNames()).containsExactlyElementsOf(SecuredDataTables.MAIN);
assertThat(SecuredDataEnums.sources()).hasSize(6);
assertThat(ClientChannelsMetaData.ofOrWithNew(instance).getEntries()).containsEntry("assistant", "secured_data_*");
assertThat(TableFeaturePermissionsMetaData.ofOrWithNew(instance).getEntries()).containsKeys("secured_data_full", "secured_data_reveal");
assertThat(instance.getTable(SecuredDataTables.ACCESS_LOG).getDisabledCapabilities()).contains(Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
assertThat(instance.getJoin(SecuredDataTables.REVEALS_JOIN)).isNotNull();
assertThat(instance.getWidget(SecuredDataTables.REVEALS_WIDGET)).isNotNull();
assertThat(instance.getApp("secured_data").getChildren()).hasSize(4);
}
/*******************************************************************************
** Config and producer contract.
*******************************************************************************/
@Test
void configAndProducerContract() throws Exception
{
SecuredDataQBitConfig config = new SecuredDataQBitConfig().withBackendName("memory")
.withDefaultTier(DataClassification.PHI, FieldAccessTier.HIDDEN).withDefaultTierByClassification(Map.of("PHI", "FULL"))
.withNameHeuristics(true).withTableMetaDataCustomizer((instance, table) -> table);
assertThat(config.defaultTier(DataClassification.PHI)).isEqualTo(FieldAccessTier.FULL);
assertThat(config.defaultTier(DataClassification.PII)).isEqualTo(FieldAccessTier.MASKED);
SecuredDataQBitProducer producer = new SecuredDataQBitProducer().withQBitConfig(config).withNamespace("test");
assertThat(producer.getNamespace()).isEqualTo("test");
assertThat(producer.getQBitConfig()).isSameAs(config);
assertThat(producer.isEnabled()).isTrue();
assertThat(new SecuredDataQBitProducer().isEnabled()).isFalse();
assertThatThrownBy(() -> new SecuredDataQBitConfig().withAccessLogQueueCapacity(0).validate()).isInstanceOf(IllegalArgumentException.class);
setup();
assertThatThrownBy(() -> new SecuredDataQBitProducer().withQBitConfig(config).produce(QContext.getQInstance())).isInstanceOf(IllegalStateException.class);
}
/*******************************************************************************
** Admin can read but cannot forge activity and assistant cannot read.
*******************************************************************************/
@Test
void adminCanReadButCannotForgeActivityAndAssistantCannotRead() throws Exception
{
QInstance instance = setup();
QContext.setQSession(user("admin", "secured_data.admin"));
assertThat(new QueryAction().execute(new QueryInput(SecuredDataTables.POLICY)).getRecords()).isEmpty();
for(String table : SecuredDataTables.SYSTEM_WRITTEN)
{
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(table).withRecord(new QRecord()))).hasMessageContaining("Permission denied");
}
QContext.setQSession(channelUser("assistant/t1", "secured_data.admin"));
for(String table : SecuredDataTables.ALL)
{
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(table))).hasMessageContaining("Permission denied");
}
QContext.setQSession(user("ordinary"));
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(SecuredDataTables.CLASSIFICATION))).hasMessageContaining("Permission denied");
}
/*******************************************************************************
** Fields picker and classification uniqueness.
*******************************************************************************/
@Test
void fieldsPickerAndClassificationUniqueness() throws Exception
{
setup();
FieldsOfTablePossibleValueSource picker = new FieldsOfTablePossibleValueSource();
assertThat(picker.search(new SearchPossibleValueSourceInput().withOtherValues(Map.of("table_name", "person")))).hasSize(4);
assertThat(picker.search(new SearchPossibleValueSourceInput())).isEmpty();
assertThat(picker.getPossibleValue("email").getId()).isEqualTo("email");
assertThat(picker.getPossibleValue(null)).isNull();
classification(null, "CONFIDENTIAL");
assertThatThrownBy(() -> classification(null, "PHI")).hasMessageContaining("system insert failed");
assertThatThrownBy(() -> classification("no_such_field", "PHI")).hasMessageContaining("system insert failed");
assertThatThrownBy(() -> insert(SecuredDataTables.POLICY, policyRow("x", "PII", "FULL").withValue("reveal_minutes", 61))).hasMessageContaining("system insert failed");
}
/*******************************************************************************
** System cannot update or delete an access log.
*******************************************************************************/
@Test
void systemCannotUpdateOrDeleteAnAccessLog() throws Exception
{
setup();
QRecord row = insert(SecuredDataTables.ACCESS_LOG, new QRecord().withValue("event_type", "VIEW").withValue("table_name", "person").withValue("created_at", NOW));
assertThatThrownBy(() -> update(SecuredDataTables.ACCESS_LOG, new QRecord().withValue("id", row.getValue("id")).withValue("outcome", "DENIED"))).isInstanceOf(Exception.class);
assertThatThrownBy(() -> delete(SecuredDataTables.ACCESS_LOG, row)).isInstanceOf(Exception.class);
assertThat(logs()).hasSize(1);
}
/*******************************************************************************
** Versions invalidate after commit and never for rollback.
*******************************************************************************/
@Test
void versionsInvalidateAfterCommitAndNeverForRollback() throws Exception
{
setup();
var runtime = SecuredDataRuntime.current();
var customizer = new com.kingsrook.qbits.secureddata.metadata.SecuredDataTableCustomizers();
var transaction = new com.kingsrook.qqq.backend.core.actions.QBackendTransaction();
var input = new InsertInput(SecuredDataTables.CLASSIFICATION).withTransaction(transaction);
long before = runtime.classificationVersion();
customizer.postInsert(input, List.of(new QRecord().withValue("id", 42)));
assertThat(runtime.classificationVersion()).isEqualTo(before);
transaction.rollback();
assertThat(runtime.classificationVersion()).isEqualTo(before);
customizer.postInsert(input, List.of(new QRecord().withValue("id", 43)));
transaction.commit();
assertThat(runtime.classificationVersion()).isGreaterThan(before);
}
/*******************************************************************************
** Code heuristics are opt in and preserve explicit classifications.
*******************************************************************************/
@Test
void codeHeuristicsAreOptInAndPreserveExplicitClassifications() throws Exception
{
QInstance instance = base();
instance.addBackend(new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData().withName("memory")
.withBackendType(com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule.class));
QTableMetaData table = person("memory");
table.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("diagnosis_code", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING));
instance.addTable(table);
new SecuredDataQBitProducer().withQBitConfig(new SecuredDataQBitConfig().withNameHeuristics(true)).produce(instance);
QContext.init(instance, user("u1"));
RUNTIMES.add(SecuredDataRuntime.current());
assertThat(SecuredDataClassifications.effective(table, "diagnosis_code")).isEqualTo(DataClassification.PHI);
assertThat(SecuredDataClassifications.effective(table, "email")).isEqualTo(DataClassification.PII);
}
}