Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cd0fd1f54
|
||
|
|
ab283c79b9
|
||
|
|
eb98ec5fd0
|
||
|
|
f09fe866ad
|
||
|
|
c4ebc6833c
|
||
|
|
2431b4723a
|
||
|
|
9fb168b96d
|
||
|
|
e0459a99ff | ||
|
|
84e2311d84
|
||
|
|
5987cb53ac
|
||
|
|
a087988f11
|
||
|
|
894c3023b4
|
||
|
|
15c2906fc1
|
||
|
|
45015b6912
|
||
|
|
0f94818084
|
||
|
|
7b302ed2b1 | ||
|
|
b917b51656 | ||
|
|
dc4734eef1 | ||
|
|
eb912ebf07 |
No files matched your search
@@ -1,12 +1,12 @@
|
|||||||
version: 2.1
|
version: 2.1
|
||||||
setup: true
|
setup: true
|
||||||
orbs:
|
orbs:
|
||||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
qqq-orb: kingsrook/qqq-orb@0.8
|
||||||
workflows:
|
workflows:
|
||||||
setup:
|
setup:
|
||||||
jobs:
|
jobs:
|
||||||
- qqq-orb/generate_pipeline:
|
- qqq-orb/generate_pipeline:
|
||||||
orb_version: 0.7.12
|
orb_version: '0.8'
|
||||||
filters:
|
filters:
|
||||||
branches:
|
branches:
|
||||||
ignore: automation/qbit-rc-pending
|
ignore: automation/qbit-rc-pending
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
name: build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
jobs:
|
||||||
|
maven:
|
||||||
|
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||||
|
secrets: inherit
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
profile: qbit
|
profile: qbit
|
||||||
orb_version: 0.7.12
|
orb_version: '0.8'
|
||||||
default_branch: develop
|
default_branch: develop
|
||||||
java_version: '21'
|
java_version: '21'
|
||||||
browsers: true
|
browsers: true
|
||||||
|
|||||||
@@ -22,8 +22,9 @@
|
|||||||
<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">
|
<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>
|
<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 4.1.0-RC.15 ships UI API 1.6.0, field access and audit value protection -->
|
||||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
|
||||||
<revision>0.1.0-SNAPSHOT</revision>
|
<revision>0.1.0-SNAPSHOT</revision>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>com.kingsrook</groupId>
|
<groupId>com.kingsrook</groupId>
|
||||||
<artifactId>qbit-build-parent</artifactId>
|
<artifactId>qbit-build-parent</artifactId>
|
||||||
<version>2.0.0</version>
|
<version>2.2.1</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>com.kingsrook.qbits</groupId>
|
<groupId>com.kingsrook.qbits</groupId>
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.kingsrook.qqq</groupId>
|
<groupId>com.kingsrook.qqq</groupId>
|
||||||
<artifactId>qqq-frontend-next</artifactId>
|
<artifactId>qqq-frontend-next</artifactId>
|
||||||
<version>1.0.0-RC.14</version>
|
<version>1.0.0-RC.38</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -75,9 +75,13 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
private long cachedVersion;
|
private long cachedVersion;
|
||||||
|
private long policiesLoadedAt;
|
||||||
private List<QRecord> policies = List.of();
|
private List<QRecord> policies = List.of();
|
||||||
private final Map<String, UserState> users = new HashMap<>();
|
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";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
@@ -292,11 +296,13 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
|||||||
private synchronized UserState state()
|
private synchronized UserState state()
|
||||||
{
|
{
|
||||||
long version = runtime.policyVersion();
|
long version = runtime.policyVersion();
|
||||||
if(cachedVersion != version)
|
if(cachedVersion != version || SecuredDataRuntime.tooOld(policiesLoadedAt))
|
||||||
{
|
{
|
||||||
|
long loadedAt = System.nanoTime();
|
||||||
policies = List.copyOf(SystemWrites.query(SecuredDataTables.POLICY, new QQueryFilter()));
|
policies = List.copyOf(SystemWrites.query(SecuredDataTables.POLICY, new QQueryFilter()));
|
||||||
users.clear();
|
users.clear();
|
||||||
cachedVersion = version;
|
cachedVersion = version;
|
||||||
|
policiesLoadedAt = loadedAt;
|
||||||
}
|
}
|
||||||
QSession session = QContext.getQSession();
|
QSession session = QContext.getQSession();
|
||||||
TreeSet<String> held = new TreeSet<>();
|
TreeSet<String> held = new TreeSet<>();
|
||||||
@@ -308,7 +314,7 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
|||||||
held.add(permission);
|
held.add(permission);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String key = JsonUtils.toJson(List.of(userId(), held));
|
String key = userId() + '\u0000' + String.join("\u0000", held);
|
||||||
UserState state = users.get(key);
|
UserState state = users.get(key);
|
||||||
if(state == null)
|
if(state == null)
|
||||||
{
|
{
|
||||||
@@ -356,11 +362,82 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
|||||||
** Stable across unrelated permissions/records; includes channel and exact expiry.
|
** Stable across unrelated permissions/records; includes channel and exact expiry.
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
@Override
|
@Override
|
||||||
public synchronized String getAccessFingerprint()
|
public String getAccessFingerprint()
|
||||||
{
|
{
|
||||||
state();
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
TreeSet<String> relevant = new TreeSet<>();
|
// 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();
|
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();
|
||||||
|
TreeSet<String> relevant = new TreeSet<>();
|
||||||
|
List<QRecord> breakGlass = state().breakGlass();
|
||||||
for(QRecord row : policies)
|
for(QRecord row : policies)
|
||||||
{
|
{
|
||||||
String permission = row.getValueString("permission_name");
|
String permission = row.getValueString("permission_name");
|
||||||
@@ -380,12 +457,15 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
List<String> breaks = activeBreakGlass().stream().map(row -> row.getValueString("id")).sorted().toList();
|
List<QRecord> activeBreaks = breakGlass.stream().filter(row -> "ACTIVE".equals(row.getValueString("status")))
|
||||||
String payload = JsonUtils.toJson(List.of(userId(), relevant, runtime.policyVersion(), runtime.classificationVersion(), breaks,
|
.filter(row -> row.getValueInstant("expires_at") != null && row.getValueInstant("expires_at").isAfter(now)).toList();
|
||||||
Objects.requireNonNullElse(SecuredDataMetadata.channel(session), "")));
|
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));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(payload.getBytes(StandardCharsets.UTF_8))));
|
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));
|
||||||
}
|
}
|
||||||
catch(java.security.NoSuchAlgorithmException e)
|
catch(java.security.NoSuchAlgorithmException e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,9 +21,21 @@
|
|||||||
package com.kingsrook.qbits.secureddata;
|
package com.kingsrook.qbits.secureddata;
|
||||||
|
|
||||||
|
|
||||||
|
import java.lang.ref.WeakReference;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
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.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.QInstance;
|
||||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||||
|
|
||||||
@@ -34,9 +46,33 @@ import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaDa
|
|||||||
public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCloseable
|
public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCloseable
|
||||||
{
|
{
|
||||||
public static final String NAME = "securedDataRuntime";
|
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 static final AtomicLong STARTUP_VERSION = new AtomicLong(System.currentTimeMillis());
|
||||||
private final AtomicLong classificationVersion = new AtomicLong(STARTUP_VERSION.incrementAndGet());
|
private final AtomicLong classificationVersion = new AtomicLong(STARTUP_VERSION.incrementAndGet());
|
||||||
private final AtomicLong policyVersion = 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 SecuredDataQBitConfig config;
|
||||||
private final transient SecuredDataResolver resolver;
|
private final transient SecuredDataResolver resolver;
|
||||||
private final transient SecuredDataKeySource keys = new SecuredDataKeySource();
|
private final transient SecuredDataKeySource keys = new SecuredDataKeySource();
|
||||||
@@ -44,6 +80,10 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
|||||||
private final transient SecuredDataAnomalies anomalies;
|
private final transient SecuredDataAnomalies anomalies;
|
||||||
private transient long cachedClassificationVersion;
|
private transient long cachedClassificationVersion;
|
||||||
private transient List<com.kingsrook.qqq.backend.core.model.data.QRecord> classificationRows = List.of();
|
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();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +93,7 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
|||||||
public SecuredDataRuntime(QInstance instance, SecuredDataQBitConfig config)
|
public SecuredDataRuntime(QInstance instance, SecuredDataQBitConfig config)
|
||||||
{
|
{
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.owner = new WeakReference<>(instance);
|
||||||
this.resolver = new SecuredDataResolver(this);
|
this.resolver = new SecuredDataResolver(this);
|
||||||
this.anomalies = new SecuredDataAnomalies(config);
|
this.anomalies = new SecuredDataAnomalies(config);
|
||||||
this.logger = new SecuredDataAccessLogger(instance, this);
|
this.logger = new SecuredDataAccessLogger(instance, this);
|
||||||
@@ -137,6 +178,7 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
|||||||
public void invalidateClassifications()
|
public void invalidateClassifications()
|
||||||
{
|
{
|
||||||
classificationVersion.incrementAndGet();
|
classificationVersion.incrementAndGet();
|
||||||
|
announce(CLASSIFICATION_CACHE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -147,6 +189,102 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
|||||||
public void invalidatePolicies()
|
public void invalidatePolicies()
|
||||||
{
|
{
|
||||||
policyVersion.incrementAndGet();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -157,11 +295,13 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
|||||||
synchronized List<com.kingsrook.qqq.backend.core.model.data.QRecord> classificationRows()
|
synchronized List<com.kingsrook.qqq.backend.core.model.data.QRecord> classificationRows()
|
||||||
{
|
{
|
||||||
long version = classificationVersion();
|
long version = classificationVersion();
|
||||||
if(cachedClassificationVersion != version)
|
if(cachedClassificationVersion != version || tooOld(classificationRowsLoadedAt))
|
||||||
{
|
{
|
||||||
|
long loadedAt = System.nanoTime();
|
||||||
classificationRows = List.copyOf(com.kingsrook.qbits.secureddata.support.SystemWrites.query(
|
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()));
|
com.kingsrook.qbits.secureddata.metadata.SecuredDataTables.CLASSIFICATION, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()));
|
||||||
cachedClassificationVersion = version;
|
cachedClassificationVersion = version;
|
||||||
|
classificationRowsLoadedAt = loadedAt;
|
||||||
}
|
}
|
||||||
return (classificationRows);
|
return (classificationRows);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -238,7 +238,8 @@ public class SecuredDataTableCustomizers implements TableCustomizerInterface
|
|||||||
}
|
}
|
||||||
SecuredDataRuntime runtime = SecuredDataRuntime.current();
|
SecuredDataRuntime runtime = SecuredDataRuntime.current();
|
||||||
Runnable invalidate = SecuredDataTables.CLASSIFICATION.equals(table) ? runtime::invalidateClassifications
|
Runnable invalidate = SecuredDataTables.CLASSIFICATION.equals(table) ? runtime::invalidateClassifications
|
||||||
: SecuredDataTables.POLICY.equals(table) || SecuredDataTables.BREAK_GLASS.equals(table) ? runtime::invalidatePolicies : null;
|
: SecuredDataTables.POLICY.equals(table) || SecuredDataTables.BREAK_GLASS.equals(table) ? runtime::invalidatePolicies
|
||||||
|
: SecuredDataTables.RETENTION_RULE.equals(table) ? runtime::invalidateRetentionRules : null;
|
||||||
if(invalidate != null)
|
if(invalidate != null)
|
||||||
{
|
{
|
||||||
QBackendTransaction active = transaction == null ? QContext.getQBackendTransaction() : transaction;
|
QBackendTransaction active = transaction == null ? QContext.getQBackendTransaction() : transaction;
|
||||||
@@ -248,6 +249,10 @@ public class SecuredDataTableCustomizers implements TableCustomizerInterface
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
if(SecuredDataTables.RETENTION_RULE.equals(table))
|
||||||
|
{
|
||||||
|
com.kingsrook.qbits.secureddata.retention.RetentionRules.editedInTransaction();
|
||||||
|
}
|
||||||
active.addAfterCommitCallback(invalidate);
|
active.addAfterCommitCallback(invalidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,15 +58,68 @@ public final class RetentionRules
|
|||||||
return (names == null ? List.of() : Arrays.stream(names.split(",", -1)).map(String::trim).toList());
|
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);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
** Fresh rules in the current transaction; edits take effect without a stale cache.
|
** 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".
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
public static List<QRecord> active(String table, String field)
|
public static List<QRecord> active(String table, String field)
|
||||||
{
|
{
|
||||||
return (SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
|
SecuredDataRuntime runtime = SecuredDataRuntime.current();
|
||||||
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true))
|
List<QRecord> rules;
|
||||||
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, table)))
|
if(runtime == null || readsOwnEdits())
|
||||||
.stream().filter(rule -> fields(rule).contains(field)).toList());
|
{
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/*
|
||||||
|
* 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