Files
qbit-secured-data/src/main/java/com/kingsrook/qbits/secureddata/SecuredDataRuntime.java
T
james.maesandClaude Fable 5.2 d99db4738f
maven / build (push) Successful in 1m17s
build / maven (push) Successful in 1m17s
fix: a rule read that asks for its own rows no longer recurses
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>
2026-11-14 01:56:42 -06:00

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);
}
}