Reading the rule rows a node keeps (classifications, active retention rules, policies) is a query on a secured-data table, and other code runs inside that query: a virtual field value provider or a post-query customizer that another QBit or the host put on the table. When that code runs a query with filter criteria, core validates the operands of classified fields and asks this QBit for the classification of the field, which needs the rows that are being read. The version of the rows was recorded only after the read returned, so the nested question started the read again, and that read asked again: StackOverflowError, and the same on every later request, since nothing was ever recorded. One classification row was enough (core runs no value provider for an empty result). - System reads of the QBit's own tables (SystemWrites.query, the audit key lookup) name the stored fields of the table. Core fills a virtual field only for a query that selects it, so no value provider runs inside a system read. - RuleRows holds one kind of rule rows and is used for all three kinds, which had the same shape. A question asked from inside a read is answered from the rows held when the read began (none before the first read) and never starts another read. The owner's monitor makes that answer private to the reading thread: every other thread waits and gets the rows the read returns. - A read that fails records nothing and clears the reading mark, so the next question reads again. The rows held before are not given to a request in place of rows that could not be read. - RetentionRules.active: a question asked from inside a transaction's read of its own rule edits is answered from the rules every thread shares instead of reading the edits again. Who can be given the nested answer: only code on the reading thread, inside SystemWrites.call, where the session is the system session and the resolver answers FULL without looking at a classification. No field access decision for a person is made from it; the request that started the read, and every request on another thread, gets the rows the read returns. Tests (SecuredDataRuleReloadTest): 9 of the 11 end in StackOverflowError without the fix. They cover a value provider and a post-query customizer on the classification, retention rule and policy tables, the answer a nested question gets in both directions of a change, the session it is asked in, a second thread waiting for the read, and failed reads (an exception and an Error) being read again. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
325 lines
13 KiB
Java
325 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.lang.ref.WeakReference;
|
|
import java.time.Duration;
|
|
import java.util.List;
|
|
import java.util.concurrent.atomic.AtomicLong;
|
|
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
|
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
|
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.logging.QLogger;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
|
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.QSupplementalInstanceMetaData;
|
|
|
|
|
|
/*******************************************************************************
|
|
** Instance-scoped services and invalidation clocks, never shared across applications.
|
|
*******************************************************************************/
|
|
public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCloseable
|
|
{
|
|
public static final String NAME = "securedDataRuntime";
|
|
|
|
private static final QLogger LOG = QLogger.getLogger(SecuredDataRuntime.class);
|
|
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
// the three sets of rule rows a node keeps in memory. A change clears them on //
|
|
// this node at once and, through QQQ's cluster bus (ClusterCaches), on every //
|
|
// other node. MAX_RULE_AGE is the safety net for a lost event: no node uses //
|
|
// rule rows older than that without reading them again. //
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
public static final String POLICY_CACHE = "securedData.policies";
|
|
public static final String CLASSIFICATION_CACHE = "securedData.classifications";
|
|
public static final String RETENTION_CACHE = "securedData.retentionRules";
|
|
|
|
public static final Duration MAX_RULE_AGE = Duration.ofSeconds(30);
|
|
|
|
static
|
|
{
|
|
ClusterCaches.shared(POLICY_CACHE, POLICY_CACHE, () -> clearOnThisNode(POLICY_CACHE), null);
|
|
ClusterCaches.shared(CLASSIFICATION_CACHE, CLASSIFICATION_CACHE, () -> clearOnThisNode(CLASSIFICATION_CACHE), null);
|
|
ClusterCaches.shared(RETENTION_CACHE, RETENTION_CACHE, () -> clearOnThisNode(RETENTION_CACHE), null);
|
|
}
|
|
|
|
private static final AtomicLong STARTUP_VERSION = new AtomicLong(System.currentTimeMillis());
|
|
private final AtomicLong classificationVersion = new AtomicLong(STARTUP_VERSION.incrementAndGet());
|
|
private final AtomicLong policyVersion = new AtomicLong(STARTUP_VERSION.incrementAndGet());
|
|
private final AtomicLong retentionVersion = new AtomicLong(STARTUP_VERSION.incrementAndGet());
|
|
private final transient WeakReference<QInstance> owner;
|
|
private final transient SecuredDataQBitConfig config;
|
|
private final transient SecuredDataResolver resolver;
|
|
private final transient SecuredDataKeySource keys = new SecuredDataKeySource();
|
|
private final transient SecuredDataAccessLogger logger;
|
|
private final transient SecuredDataAnomalies anomalies;
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
// both are used only inside methods synchronized on this runtime: one monitor //
|
|
// for the two, because reading either may ask for the other //
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
private final transient RuleRows classificationRows = new RuleRows();
|
|
private final transient RuleRows activeRetentionRules = new RuleRows();
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Construct services only after tables have been registered.
|
|
*******************************************************************************/
|
|
public SecuredDataRuntime(QInstance instance, SecuredDataQBitConfig config)
|
|
{
|
|
this.config = config;
|
|
this.owner = new WeakReference<>(instance);
|
|
this.resolver = new SecuredDataResolver(this);
|
|
this.anomalies = new SecuredDataAnomalies(config);
|
|
this.logger = new SecuredDataAccessLogger(instance, this);
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Supplemental metadata identity.
|
|
*******************************************************************************/
|
|
@Override
|
|
public String getName()
|
|
{
|
|
return (NAME);
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Services for the current QContext.
|
|
*******************************************************************************/
|
|
public static SecuredDataRuntime current()
|
|
{
|
|
return (QContext.getQInstance() == null ? null : QSupplementalInstanceMetaData.of(QContext.getQInstance(), NAME));
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Code configuration.
|
|
*******************************************************************************/
|
|
public SecuredDataQBitConfig config()
|
|
{
|
|
return (config);
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Resolver service.
|
|
*******************************************************************************/
|
|
public SecuredDataResolver resolver()
|
|
{
|
|
return (resolver);
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Logger service and drop counter.
|
|
*******************************************************************************/
|
|
public SecuredDataAccessLogger logger()
|
|
{
|
|
return (logger);
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Classification invalidation clock.
|
|
*******************************************************************************/
|
|
public long classificationVersion()
|
|
{
|
|
return (classificationVersion.get());
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Policy/break-glass invalidation clock.
|
|
*******************************************************************************/
|
|
public long policyVersion()
|
|
{
|
|
return (policyVersion.get());
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Post-commit customizers and host distributed invalidation call this.
|
|
*******************************************************************************/
|
|
public void invalidateClassifications()
|
|
{
|
|
classificationVersion.incrementAndGet();
|
|
announce(CLASSIFICATION_CACHE);
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Post-commit customizers and host distributed invalidation call this.
|
|
*******************************************************************************/
|
|
public void invalidatePolicies()
|
|
{
|
|
policyVersion.incrementAndGet();
|
|
announce(POLICY_CACHE);
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** A retention rule changed: forget the active rules here and on every node.
|
|
*******************************************************************************/
|
|
public void invalidateRetentionRules()
|
|
{
|
|
retentionVersion.incrementAndGet();
|
|
announce(RETENTION_CACHE);
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Version of the retention rules this node holds.
|
|
*******************************************************************************/
|
|
public long retentionVersion()
|
|
{
|
|
return (retentionVersion.get());
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Whether rows loaded at this System.nanoTime are too old to use unread.
|
|
*******************************************************************************/
|
|
static boolean tooOld(long loadedAtNanos)
|
|
{
|
|
return (System.nanoTime() - loadedAtNanos > MAX_RULE_AGE.toNanos());
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Every active retention rule, read once per version and at most MAX_RULE_AGE
|
|
** old. A read that fails throws and leaves nothing in place of the rules:
|
|
** callers never see "no rules" because the rules could not be read, and the
|
|
** next call reads again. A question asked from inside the read itself (see
|
|
** RuleRows) is answered with the rules held when the read began.
|
|
*******************************************************************************/
|
|
public synchronized List<QRecord> activeRetentionRules()
|
|
{
|
|
return (activeRetentionRules.get(retentionVersion(), () -> SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
|
|
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true)))));
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Tell the other nodes (this node has already moved its version). A failure
|
|
** to send is logged: the other nodes then read again within MAX_RULE_AGE.
|
|
*******************************************************************************/
|
|
private void announce(String cache)
|
|
{
|
|
try
|
|
{
|
|
QInstance instance = owner.get();
|
|
if(instance != null)
|
|
{
|
|
ClusterBus.publish(instance, new ClusterEvent(ClusterEvent.TYPE_MEMO_INVALIDATE, cache, null));
|
|
}
|
|
}
|
|
catch(RuntimeException e)
|
|
{
|
|
LOG.warn("Could not announce a secured-data rule change; other nodes read again within " + MAX_RULE_AGE.toSeconds() + " seconds", e);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** A rule change announced on the cluster bus (also by this node): move the
|
|
** version of the runtime of the instance the event arrived on.
|
|
*******************************************************************************/
|
|
private static void clearOnThisNode(String cache)
|
|
{
|
|
SecuredDataRuntime runtime = current();
|
|
if(runtime == null)
|
|
{
|
|
return;
|
|
}
|
|
switch(cache)
|
|
{
|
|
case POLICY_CACHE -> runtime.policyVersion.incrementAndGet();
|
|
case CLASSIFICATION_CACHE -> runtime.classificationVersion.incrementAndGet();
|
|
default -> runtime.retentionVersion.incrementAndGet();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Read classification rows once per version, always as the system. A read
|
|
** that fails throws and is tried again by the next call. A question asked from
|
|
** inside the read itself (see RuleRows) is answered with the rows held when
|
|
** the read began, and never starts another read.
|
|
*******************************************************************************/
|
|
synchronized List<QRecord> classificationRows()
|
|
{
|
|
return (classificationRows.get(classificationVersion(), () -> SystemWrites.query(SecuredDataTables.CLASSIFICATION, new QQueryFilter())));
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** Drain the audit queue during normal host shutdown.
|
|
*******************************************************************************/
|
|
@Override
|
|
public void close()
|
|
{
|
|
keys.close();
|
|
logger.close();
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Anomaly counters shared by synchronous reveals and queued searches.
|
|
*******************************************************************************/
|
|
public SecuredDataAnomalies anomalies()
|
|
{
|
|
return (anomalies);
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Instance-scoped audit keys.
|
|
*******************************************************************************/
|
|
public SecuredDataKeySource keys()
|
|
{
|
|
return (keys);
|
|
}
|
|
}
|