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>
262 lines
11 KiB
Java
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);
|
|
}
|
|
}
|