Files
qbit-secured-data/src/test/java/com/kingsrook/qbits/secureddata/RetentionTestSupport.java
T
james.maesandClaude Opus 5.5 470b0bd214 feat: retention and wipe with crypto-shredding; move to QQQ RC.6 (phase f)
Moves the pin to QQQ 4.1.0-RC.6 and removes the stand-ins RC.6 provides
(classification, client channels, table features, the UI API extension
point). Adds retention rules, wipe markers, legal holds, wipe runs and the
per-record-per-field audit key table; the wipe process (null with marker,
overwrite, keyed hash, delete, archive then wipe) runs in one transaction
per batch, destroying the audit keys with the field updates; the audit
protector and key source (AES-GCM wrapped DEKs, AAD-bound); the backups
warning; canary and wipe-proof tests.

Fixes a key-source bug that zeroed each DEK after returning it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:35:45 -05:00

148 lines
8.1 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.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qbits.secureddata.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
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.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
final class RetentionTestSupport
{
static String BACKEND = "retention_rdbms";
static final String KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
static final Instant OLD = SecuredDataTestSupport.NOW.minusSeconds(60L * 86400);
static QInstance setup() throws Exception
{
SecuredDataTestSupport.close();
System.setProperty("QQQ_SECURED_DATA_KEK", KEY);
System.setProperty("QQQ_AUDIT_HMAC_KEY", KEY);
SecuredDataTestSupport.CLOCK.now = SecuredDataTestSupport.NOW;
String db = "retention_" + UUID.randomUUID().toString().replace("-", "");
BACKEND = db; // pools are cached by backend name, so each instance needs its own
String url = "jdbc:h2:mem:" + db + ";MODE=PostgreSQL;DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1";
RDBMSBackendMetaData backend = new RDBMSBackendMetaData().withName(BACKEND).withVendor("h2").withHostName("mem").withDatabaseName(db).withUsername("sa");
backend.setJdbcUrl(url);
QInstance instance = SecuredDataTestSupport.base();
instance.addBackend(backend);
QTableMetaData table = new QTableMetaData().withName("reading").withLabel("Reading").withBackendName(BACKEND).withPrimaryKeyField("id")
.withBackendDetails(new RDBMSTableBackendDetails().withTableName("reading"))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
.withField(new QFieldMetaData("reading_time", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("reading_date", QFieldType.DATE))
.withField(new QFieldMetaData("systolic", QFieldType.INTEGER).withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI)))
.withField(new QFieldMetaData("secret", QFieldType.STRING).withMaxLength(255).withBehavior(com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior.ERROR).withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI)))
.withField(new QFieldMetaData("notes", QFieldType.TEXT).withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII)));
table.getFields().values().forEach(f -> f.setBackendName(f.getName()));
instance.addTable(table);
new AuditsMetaDataProvider().defineAll(instance, BACKEND, t -> t.withBackendDetails(new RDBMSTableBackendDetails().withTableName(t.getName())));
try(Connection connection = DriverManager.getConnection(url, "sa", ""))
{
for(QTableMetaData t : instance.getTables().values())
{
StringBuilder sql = new StringBuilder("CREATE TABLE ").append(t.getName()).append(" (");
for(QFieldMetaData field : t.getFields().values())
{
field.setBackendName(field.getName());
sql.append(field.getName()).append(" ");
if(field.getName().equals(t.getPrimaryKeyField()))
{
sql.append("INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY");
}
else
{
sql.append(switch(field.getType())
{
case INTEGER -> "INTEGER";
case LONG -> "BIGINT";
case DATE_TIME -> "TIMESTAMP WITH TIME ZONE";
case DATE -> "DATE";
case BOOLEAN -> "BOOLEAN";
case DECIMAL -> "DECIMAL";
default -> "TEXT";
});
}
sql.append(",");
}
sql.setLength(sql.length() - 1);
connection.createStatement().execute(sql.append(")").toString());
}
for(String sql : Files.readString(Path.of("docs/ddl/secured-data-postgres.sql")).split(";"))
{
if(!sql.isBlank()) connection.createStatement().execute(sql);
}
}
new SecuredDataQBitProducer().withQBitConfig(new SecuredDataQBitConfig().withBackendName(BACKEND).withClock(SecuredDataTestSupport.CLOCK)
.withBackupRetentionDays(35).withAccessLogFlushIntervalMillis(60000)
.withTableMetaDataCustomizer((i,t) -> t.withBackendDetails(new RDBMSTableBackendDetails().withTableName(t.getName())))).produce(instance);
QContext.init(instance, SecuredDataTestSupport.user("admin", "secured_data.admin", "secured_data.retention", "reading.read", "reading.write"));
SecuredDataTestSupport.RUNTIMES.add(SecuredDataRuntime.current());
new QInstanceValidator().validate(instance);
return instance;
}
static QRecord rule(String action, String fields) throws Exception
{
return SystemWrites.insertOne(SecuredDataTables.RETENTION_RULE, new QRecord().withValue("name", "30-day policy").withValue("table_name", "reading")
.withValue("field_names", fields).withValue("age_field_name", "reading_time").withValue("age_days", 30).withValue("action", action)
.withValue("archive_storage_name", "archive"));
}
static QRecord reading(Object value) throws Exception
{
return SystemWrites.insertOne("reading", new QRecord().withValue("systolic", (java.io.Serializable)value).withValue("secret", "test-secret")
.withValue("notes", "test-notes").withValue("reading_time", OLD).withValue("reading_date", OLD.atZone(java.time.ZoneOffset.UTC).toLocalDate()));
}
static List<QRecord> rows(String table)
{
return SystemWrites.query(table, new QQueryFilter());
}
static void close()
{
SecuredDataTestSupport.close();
System.clearProperty("QQQ_SECURED_DATA_KEK");
System.clearProperty("QQQ_AUDIT_HMAC_KEY");
System.clearProperty("QQQ_AUDIT_HMAC_KEY_ID");
}
}