Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f7d3d440
|
No files matched your search
@@ -1,12 +1,12 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.11
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
orb_version: 0.7.11
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -1,23 +0,0 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@7d73ac90b6b5a6acb61b4d3fa56625c5b6dcc652
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
orb_version: 0.7.11
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
|
||||
@@ -22,9 +22,8 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<!-- QQQ 4.1.0-RC.15 ships UI API 1.6.0, field access and audit value protection -->
|
||||
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -35,7 +34,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.2.1</version>
|
||||
<version>2.0.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -93,7 +92,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.38</version>
|
||||
<version>1.0.0-RC.14</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -75,13 +75,9 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
||||
}
|
||||
|
||||
private long cachedVersion;
|
||||
private long policiesLoadedAt;
|
||||
private List<QRecord> policies = List.of();
|
||||
private final Map<String, UserState> users = new HashMap<>();
|
||||
|
||||
/** Request-scoped (QContext) holder of the access fingerprint. */
|
||||
private static final String FINGERPRINT_KEY = SecuredDataResolver.class.getName() + ".accessFingerprint";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -296,13 +292,11 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
||||
private synchronized UserState state()
|
||||
{
|
||||
long version = runtime.policyVersion();
|
||||
if(cachedVersion != version || SecuredDataRuntime.tooOld(policiesLoadedAt))
|
||||
if(cachedVersion != version)
|
||||
{
|
||||
long loadedAt = System.nanoTime();
|
||||
policies = List.copyOf(SystemWrites.query(SecuredDataTables.POLICY, new QQueryFilter()));
|
||||
users.clear();
|
||||
cachedVersion = version;
|
||||
policiesLoadedAt = loadedAt;
|
||||
}
|
||||
QSession session = QContext.getQSession();
|
||||
TreeSet<String> held = new TreeSet<>();
|
||||
@@ -314,7 +308,7 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
||||
held.add(permission);
|
||||
}
|
||||
}
|
||||
String key = userId() + '\u0000' + String.join("\u0000", held);
|
||||
String key = JsonUtils.toJson(List.of(userId(), held));
|
||||
UserState state = users.get(key);
|
||||
if(state == null)
|
||||
{
|
||||
@@ -362,82 +356,11 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
||||
** Stable across unrelated permissions/records; includes channel and exact expiry.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getAccessFingerprint()
|
||||
public synchronized String getAccessFingerprint()
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// core asks for the fingerprint before every field decision (once per //
|
||||
// field of every record of a result), so the value is kept for the //
|
||||
// request - and only while everything it is derived from is unchanged //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
QSession session = QContext.getQSession();
|
||||
String channel = Objects.requireNonNullElse(SecuredDataMetadata.channel(session), "");
|
||||
Instant now = runtime.config().getClock().instant();
|
||||
RequestFingerprint kept = QContext.getObject(FINGERPRINT_KEY, RequestFingerprint.class).orElse(null);
|
||||
if(kept != null && kept.matches(this, session, channel, runtime.policyVersion(), runtime.classificationVersion(), now))
|
||||
{
|
||||
return (kept.value());
|
||||
}
|
||||
RequestFingerprint computed = computeAccessFingerprint(session, channel, now);
|
||||
QContext.setObject(FINGERPRINT_KEY, computed);
|
||||
return (computed.value());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fingerprint and what it was derived from. It stops matching when the
|
||||
** session, its permissions, the channel, a policy or classification version
|
||||
** changes, or when the first active break-glass grant expires.
|
||||
*******************************************************************************/
|
||||
private record RequestFingerprint(int resolver, QSession session, int permissionsIdentity, int permissionCount, String channel, long policyVersion,
|
||||
long classificationVersion, Instant validUntil, String value) implements Serializable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Whether the kept value is still the one a fresh computation would give.
|
||||
*******************************************************************************/
|
||||
boolean matches(SecuredDataResolver owner, QSession current, String currentChannel, long currentPolicyVersion, long currentClassificationVersion, Instant now)
|
||||
{
|
||||
return (resolver == System.identityHashCode(owner) && session == current && permissionsIdentity == identityOfPermissions(current) && permissionCount == countOfPermissions(current)
|
||||
&& channel.equals(currentChannel) && policyVersion == currentPolicyVersion && classificationVersion == currentClassificationVersion && now.isBefore(validUntil));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission set object of a session (it is replaced, not edited, when a
|
||||
** session is given new permissions).
|
||||
*******************************************************************************/
|
||||
private static int identityOfPermissions(QSession session)
|
||||
{
|
||||
return (session == null || session.getPermissions() == null ? 0 : System.identityHashCode(session.getPermissions()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How many permissions a session holds (an edit in place changes it).
|
||||
*******************************************************************************/
|
||||
private static int countOfPermissions(QSession session)
|
||||
{
|
||||
return (session == null || session.getPermissions() == null ? -1 : session.getPermissions().size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable across unrelated permissions/records; includes channel and exact expiry.
|
||||
*******************************************************************************/
|
||||
private synchronized RequestFingerprint computeAccessFingerprint(QSession session, String channel, Instant now)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// versions are read before the rows they describe, so a change during the //
|
||||
// computation leaves a kept value that no longer matches //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
long policyVersion = runtime.policyVersion();
|
||||
long classificationVersion = runtime.classificationVersion();
|
||||
state();
|
||||
TreeSet<String> relevant = new TreeSet<>();
|
||||
List<QRecord> breakGlass = state().breakGlass();
|
||||
QSession session = QContext.getQSession();
|
||||
for(QRecord row : policies)
|
||||
{
|
||||
String permission = row.getValueString("permission_name");
|
||||
@@ -457,15 +380,12 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
||||
}
|
||||
}
|
||||
}
|
||||
List<QRecord> activeBreaks = breakGlass.stream().filter(row -> "ACTIVE".equals(row.getValueString("status")))
|
||||
.filter(row -> row.getValueInstant("expires_at") != null && row.getValueInstant("expires_at").isAfter(now)).toList();
|
||||
List<String> breaks = activeBreaks.stream().map(row -> row.getValueString("id")).sorted().toList();
|
||||
Instant validUntil = activeBreaks.stream().map(row -> row.getValueInstant("expires_at")).min(Instant::compareTo).orElse(Instant.MAX);
|
||||
String payload = JsonUtils.toJson(List.of(userId(), relevant, policyVersion, classificationVersion, breaks, channel));
|
||||
List<String> breaks = activeBreakGlass().stream().map(row -> row.getValueString("id")).sorted().toList();
|
||||
String payload = JsonUtils.toJson(List.of(userId(), relevant, runtime.policyVersion(), runtime.classificationVersion(), breaks,
|
||||
Objects.requireNonNullElse(SecuredDataMetadata.channel(session), "")));
|
||||
try
|
||||
{
|
||||
String value = Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(payload.getBytes(StandardCharsets.UTF_8)));
|
||||
return (new RequestFingerprint(System.identityHashCode(this), session, identityOfPermissions(session), countOfPermissions(session), channel, policyVersion, classificationVersion, validUntil, value));
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(payload.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(java.security.NoSuchAlgorithmException e)
|
||||
{
|
||||
|
||||
@@ -21,21 +21,9 @@
|
||||
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;
|
||||
|
||||
@@ -46,33 +34,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaDa
|
||||
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();
|
||||
@@ -80,10 +44,6 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
||||
private final transient SecuredDataAnomalies anomalies;
|
||||
private transient long cachedClassificationVersion;
|
||||
private transient List<com.kingsrook.qqq.backend.core.model.data.QRecord> classificationRows = List.of();
|
||||
private transient long classificationRowsLoadedAt;
|
||||
private transient long cachedRetentionVersion;
|
||||
private transient long retentionRulesLoadedAt;
|
||||
private transient List<QRecord> activeRetentionRules = List.of();
|
||||
|
||||
|
||||
|
||||
@@ -93,7 +53,6 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
||||
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);
|
||||
@@ -178,7 +137,6 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
||||
public void invalidateClassifications()
|
||||
{
|
||||
classificationVersion.incrementAndGet();
|
||||
announce(CLASSIFICATION_CACHE);
|
||||
}
|
||||
|
||||
|
||||
@@ -189,102 +147,6 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
||||
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.
|
||||
*******************************************************************************/
|
||||
public synchronized List<QRecord> activeRetentionRules()
|
||||
{
|
||||
long version = retentionVersion();
|
||||
if(cachedRetentionVersion != version || tooOld(retentionRulesLoadedAt))
|
||||
{
|
||||
long loadedAt = System.nanoTime();
|
||||
activeRetentionRules = List.copyOf(SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true))));
|
||||
cachedRetentionVersion = version;
|
||||
retentionRulesLoadedAt = loadedAt;
|
||||
}
|
||||
return (activeRetentionRules);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -295,13 +157,11 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
||||
synchronized List<com.kingsrook.qqq.backend.core.model.data.QRecord> classificationRows()
|
||||
{
|
||||
long version = classificationVersion();
|
||||
if(cachedClassificationVersion != version || tooOld(classificationRowsLoadedAt))
|
||||
if(cachedClassificationVersion != version)
|
||||
{
|
||||
long loadedAt = System.nanoTime();
|
||||
classificationRows = List.copyOf(com.kingsrook.qbits.secureddata.support.SystemWrites.query(
|
||||
com.kingsrook.qbits.secureddata.metadata.SecuredDataTables.CLASSIFICATION, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()));
|
||||
cachedClassificationVersion = version;
|
||||
classificationRowsLoadedAt = loadedAt;
|
||||
}
|
||||
return (classificationRows);
|
||||
}
|
||||
|
||||
+1
-6
@@ -238,8 +238,7 @@ public class SecuredDataTableCustomizers implements TableCustomizerInterface
|
||||
}
|
||||
SecuredDataRuntime runtime = SecuredDataRuntime.current();
|
||||
Runnable invalidate = SecuredDataTables.CLASSIFICATION.equals(table) ? runtime::invalidateClassifications
|
||||
: SecuredDataTables.POLICY.equals(table) || SecuredDataTables.BREAK_GLASS.equals(table) ? runtime::invalidatePolicies
|
||||
: SecuredDataTables.RETENTION_RULE.equals(table) ? runtime::invalidateRetentionRules : null;
|
||||
: SecuredDataTables.POLICY.equals(table) || SecuredDataTables.BREAK_GLASS.equals(table) ? runtime::invalidatePolicies : null;
|
||||
if(invalidate != null)
|
||||
{
|
||||
QBackendTransaction active = transaction == null ? QContext.getQBackendTransaction() : transaction;
|
||||
@@ -249,10 +248,6 @@ public class SecuredDataTableCustomizers implements TableCustomizerInterface
|
||||
}
|
||||
else
|
||||
{
|
||||
if(SecuredDataTables.RETENTION_RULE.equals(table))
|
||||
{
|
||||
com.kingsrook.qbits.secureddata.retention.RetentionRules.editedInTransaction();
|
||||
}
|
||||
active.addAfterCommitCallback(invalidate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,68 +58,15 @@ public final class RetentionRules
|
||||
return (names == null ? List.of() : Arrays.stream(names.split(",", -1)).map(String::trim).toList());
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// set on a thread that changed a rule inside a transaction: until that //
|
||||
// transaction ends, the thread reads the rules through it (its own edits), //
|
||||
// not the rules every other thread shares //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
private static final ThreadLocal<Boolean> EDITED_IN_TRANSACTION = ThreadLocal.withInitial(() -> false);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The active rules of a table that name this field. Core asks this for every
|
||||
** field of every audited write and every filter criterion of every query, so
|
||||
** the active rules are read once (SecuredDataRuntime.activeRetentionRules: per
|
||||
** change, and at most MAX_RULE_AGE old) instead of once per call. A committed
|
||||
** change is seen at once on the node that made it and on every node the
|
||||
** cluster event reaches; a transaction that edits rules reads its own edits.
|
||||
** A failed read throws: it is never "no rules".
|
||||
** Fresh rules in the current transaction; edits take effect without a stale cache.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> active(String table, String field)
|
||||
{
|
||||
SecuredDataRuntime runtime = SecuredDataRuntime.current();
|
||||
List<QRecord> rules;
|
||||
if(runtime == null || readsOwnEdits())
|
||||
{
|
||||
rules = SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true))
|
||||
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, table)));
|
||||
}
|
||||
else
|
||||
{
|
||||
rules = runtime.activeRetentionRules();
|
||||
}
|
||||
return (rules.stream().filter(rule -> table != null && table.equals(rule.getValueString("table_name"))).filter(rule -> fields(rule).contains(field)).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A rule was written on this thread inside a transaction that is still open.
|
||||
*******************************************************************************/
|
||||
public static void editedInTransaction()
|
||||
{
|
||||
EDITED_IN_TRANSACTION.set(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this thread must read the rules through its open transaction.
|
||||
*******************************************************************************/
|
||||
private static boolean readsOwnEdits()
|
||||
{
|
||||
if(!EDITED_IN_TRANSACTION.get())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(QContext.getQBackendTransaction() == null)
|
||||
{
|
||||
EDITED_IN_TRANSACTION.remove();
|
||||
return (false);
|
||||
}
|
||||
return (true);
|
||||
return (SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true))
|
||||
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, table)))
|
||||
.stream().filter(rule -> fields(rule).contains(field)).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user