Files
qbit-secured-data/src/test/java/com/kingsrook/qbits/secureddata/SecuredDataRuleCachesTest.java
T
james.maesandClaude Fable 5.2 ab283c79b9
maven / build (push) Successful in 1m32s
build / maven (push) Successful in 1m32s
perf: keep the access fingerprint for the request and read rule rows once per change
Core asks the field access resolver for its fingerprint before every field
decision (FieldAccessHelper.getDecision), that is once per field of every
record of a result and of every table in the metaData document. Each call
recomputed it: a JSON key, a permission lookup for every table, another JSON
document and a digest. Core also asks for the retention rules of a field on
every audited field and every filter criterion, and each question was a
query.

- getAccessFingerprint keeps its value in the request context (QContext) and
  returns it while the session, its permission set, the channel, the policy
  and classification versions are unchanged and no active break-glass grant
  has expired. Nothing outlives the request.
- RetentionRules.active answers from the active rules the runtime read once
  (SecuredDataRuntime.activeRetentionRules). A rule write clears them after
  commit; a thread that edits rules inside a transaction reads its own edits.
- Policy, classification and retention changes are announced on QQQ's cluster
  bus (ClusterCaches, memo-invalidate), so every node reads again. Before,
  the versions were node-local and another node kept old rules until restart.
- No node uses rule rows older than 30 seconds without reading them again
  (the safety net for a lost event).
- A read that fails throws and replaces nothing: never an empty rule list.

Measured in one JVM with GG Forge's composed instance (222 tables, secured
data and collaboration wired, in-memory backend, platform administrator),
released 0.1.0-RC.4 against this change, same machine, back to back:
metaData request 866.6 ms to 125.3 ms (930 MB to 74 MB allocated, 96 to 34
queries); a 300-row list 1,566 ms to 24 ms; a 27-row list 174 to 230 ms down
to 13 to 17 ms; queries per list 23 to 8.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 15:22:22 -06:00

262 lines
11 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.ArrayList;
import java.util.HashSet;
import java.util.List;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qbits.secureddata.permissions.SecuredDataPermissions;
import com.kingsrook.qbits.secureddata.retention.RetentionRules;
import com.kingsrook.qbits.secureddata.support.SecuredDataMetadata;
import com.kingsrook.qqq.backend.core.cluster.ClusterBus;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.cluster.ClusterEvent;
import com.kingsrook.qqq.backend.core.context.QContext;
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.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.session.QSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.setup;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.update;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The rule rows a node keeps (retention rules, policies, classifications) and
** the access fingerprint of a request: read once, cleared by every change on
** every node, never older than the maximum age, and never an empty answer when
** the rules cannot be read.
*******************************************************************************/
class SecuredDataRuleCachesTest
{
private static final List<String> ANNOUNCED = new ArrayList<>();
static
{
ClusterBus.on(ClusterEvent.TYPE_MEMO_INVALIDATE, (instance, event) -> ANNOUNCED.add(event.getName()));
}
QInstance instance;
QTableMetaData person;
SecuredDataRuntime runtime;
/*******************************************************************************
** A person table with a date, so a retention rule on it is valid.
*******************************************************************************/
@BeforeEach
void init() throws Exception
{
instance = setup();
person = instance.getTable("person");
person.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withBackendName("createDate"));
runtime = SecuredDataRuntime.current();
MemoryRecordStore.setCollectStatistics(true);
MemoryRecordStore.resetStatistics();
ANNOUNCED.clear();
}
/*******************************************************************************
** Cleanup.
*******************************************************************************/
@AfterEach
void cleanup()
{
MemoryRecordStore.setCollectStatistics(false);
close();
}
/*******************************************************************************
** An active rule on one field of the person table.
*******************************************************************************/
private static QRecord rule(String field) throws Exception
{
return (insert(SecuredDataTables.RETENTION_RULE, new QRecord().withValue("name", "30 days: " + field).withValue("table_name", "person")
.withValue("field_names", field).withValue("age_field_name", "createDate").withValue("age_days", 30).withValue("action", "NULL_WITH_MARKER")));
}
/*******************************************************************************
** Queries the in-memory store has run since the last reset.
*******************************************************************************/
private static int queries()
{
return (MemoryRecordStore.getStatistics().getOrDefault(MemoryRecordStore.STAT_QUERIES_RAN, 0));
}
/*******************************************************************************
** Core asks for the rules of a field on every audited field and filter
** criterion: many questions are one read, and every committed change (insert,
** update, delete) is seen by the next question.
*******************************************************************************/
@Test
void activeRetentionRulesAreReadOncePerChange() throws Exception
{
QRecord notes = rule("notes");
assertThat(notes.getErrors()).isNullOrEmpty();
MemoryRecordStore.resetStatistics();
for(int i = 0; i < 50; i++)
{
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
assertThat(RetentionRules.active("person", "email")).isEmpty();
assertThat(RetentionRules.active("anotherTable", "notes")).isEmpty();
assertThat(RetentionRules.changedOnly("person", "notes")).isFalse();
}
assertThat(queries()).isLessThanOrEqualTo(1);
QRecord ssn = rule("ssn");
assertThat(RetentionRules.active("person", "ssn")).hasSize(1);
update(SecuredDataTables.RETENTION_RULE, new QRecord().withValue("id", notes.getValue("id")).withValue("is_active", false));
assertThat(RetentionRules.active("person", "notes")).isEmpty();
delete(SecuredDataTables.RETENTION_RULE, ssn);
assertThat(RetentionRules.active("person", "ssn")).isEmpty();
assertThat(ANNOUNCED).contains(SecuredDataRuntime.RETENTION_CACHE);
}
/*******************************************************************************
** A rule change made on another node arrives as a cluster cache event: each
** of the three kinds of rules is read again here. A change made here is
** announced to the other nodes.
*******************************************************************************/
@Test
void aChangeOnAnyNodeClearsTheRulesOnEveryNode() throws Exception
{
rule("notes");
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
long retention = runtime.retentionVersion();
long policies = runtime.policyVersion();
long classifications = runtime.classificationVersion();
ClusterCaches.invalidate(SecuredDataRuntime.RETENTION_CACHE);
ClusterCaches.invalidate(SecuredDataRuntime.POLICY_CACHE);
ClusterCaches.invalidate(SecuredDataRuntime.CLASSIFICATION_CACHE);
assertThat(runtime.retentionVersion()).isGreaterThan(retention);
assertThat(runtime.policyVersion()).isGreaterThan(policies);
assertThat(runtime.classificationVersion()).isGreaterThan(classifications);
MemoryRecordStore.resetStatistics();
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
assertThat(queries()).isGreaterThanOrEqualTo(1);
MemoryRecordStore.resetStatistics();
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
assertThat(queries()).isZero();
ANNOUNCED.clear();
runtime.invalidatePolicies();
runtime.invalidateClassifications();
runtime.invalidateRetentionRules();
assertThat(ANNOUNCED).contains(SecuredDataRuntime.POLICY_CACHE, SecuredDataRuntime.CLASSIFICATION_CACHE, SecuredDataRuntime.RETENTION_CACHE);
}
/*******************************************************************************
** Fail closed: when the rules cannot be read, asking for them throws. The
** rules read before are not used, and the answer is never an empty list.
*******************************************************************************/
@Test
void rulesThatCannotBeReadAreNeverAnEmptyAnswer() throws Exception
{
rule("notes");
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
QTableMetaData ruleTable = instance.getTables().remove(SecuredDataTables.RETENTION_RULE);
try
{
runtime.invalidateRetentionRules();
assertThatThrownBy(() -> RetentionRules.active("person", "notes")).isInstanceOf(RuntimeException.class);
assertThatThrownBy(() -> RetentionRules.active("person", "notes")).isInstanceOf(RuntimeException.class);
}
finally
{
instance.getTables().put(SecuredDataTables.RETENTION_RULE, ruleTable);
}
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
}
/*******************************************************************************
** The safety net for a lost cluster event: rows are read again once they are
** older than the maximum age.
*******************************************************************************/
@Test
void ruleRowsAreNotUsedPastTheirMaximumAge()
{
assertThat(SecuredDataRuntime.tooOld(System.nanoTime())).isFalse();
assertThat(SecuredDataRuntime.tooOld(System.nanoTime() - SecuredDataRuntime.MAX_RULE_AGE.plusSeconds(1).toNanos())).isTrue();
assertThat(SecuredDataRuntime.MAX_RULE_AGE.toSeconds()).isLessThanOrEqualTo(60);
}
/*******************************************************************************
** Core asks for the access fingerprint before every field decision. Within a
** request the same value object is returned; a permission that matters, given
** to the same session object, changes it at once.
*******************************************************************************/
@Test
void theAccessFingerprintIsKeptForTheRequestAndTracksThePermissions()
{
SecuredDataResolver resolver = runtime.resolver();
QSession session = QContext.getQSession();
String first = resolver.getAccessFingerprint();
for(int i = 0; i < 20; i++)
{
assertThat(resolver.getAccessFingerprint()).isSameAs(first);
}
session.getPermissions().add("unrelated.permission");
assertThat(resolver.getAccessFingerprint()).isEqualTo(first);
session.getPermissions().add(SecuredDataMetadata.permissionBase(person) + "." + SecuredDataPermissions.FULL);
String granted = resolver.getAccessFingerprint();
assertThat(granted).isNotEqualTo(first);
session.setPermissions(new HashSet<>(List.of("person.read")));
assertThat(resolver.getAccessFingerprint()).isNotEqualTo(granted);
}
}