Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f04dd3e91
|
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.8
|
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||||
workflows:
|
workflows:
|
||||||
setup:
|
setup:
|
||||||
jobs:
|
jobs:
|
||||||
- qqq-orb/generate_pipeline:
|
- qqq-orb/generate_pipeline:
|
||||||
orb_version: '0.8'
|
orb_version: 0.7.12
|
||||||
filters:
|
filters:
|
||||||
branches:
|
branches:
|
||||||
ignore: automation/qbit-rc-pending
|
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
-1
@@ -1,5 +1,5 @@
|
|||||||
profile: qbit
|
profile: qbit
|
||||||
orb_version: '0.8'
|
orb_version: 0.7.12
|
||||||
default_branch: develop
|
default_branch: develop
|
||||||
java_version: '21'
|
java_version: '21'
|
||||||
browsers: true
|
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">
|
<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.26</qqq.snapshot.version>
|
<qqq.snapshot.version>4.1.0-RC.15</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>
|
||||||
@@ -35,7 +34,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>com.kingsrook</groupId>
|
<groupId>com.kingsrook</groupId>
|
||||||
<artifactId>qbit-build-parent</artifactId>
|
<artifactId>qbit-build-parent</artifactId>
|
||||||
<version>2.2.1</version>
|
<version>2.0.0</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>com.kingsrook.qbits</groupId>
|
<groupId>com.kingsrook.qbits</groupId>
|
||||||
@@ -93,7 +92,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.38</version>
|
<version>1.0.0-RC.14</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -75,13 +75,9 @@ 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";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
@@ -296,13 +292,11 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
|||||||
private synchronized UserState state()
|
private synchronized UserState state()
|
||||||
{
|
{
|
||||||
long version = runtime.policyVersion();
|
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()));
|
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<>();
|
||||||
@@ -314,7 +308,7 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
|||||||
held.add(permission);
|
held.add(permission);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String key = userId() + '\u0000' + String.join("\u0000", held);
|
String key = JsonUtils.toJson(List.of(userId(), held));
|
||||||
UserState state = users.get(key);
|
UserState state = users.get(key);
|
||||||
if(state == null)
|
if(state == null)
|
||||||
{
|
{
|
||||||
@@ -362,82 +356,11 @@ 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 String getAccessFingerprint()
|
public synchronized String getAccessFingerprint()
|
||||||
{
|
{
|
||||||
/////////////////////////////////////////////////////////////////////////////
|
state();
|
||||||
// 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();
|
|
||||||
TreeSet<String> relevant = new TreeSet<>();
|
TreeSet<String> relevant = new TreeSet<>();
|
||||||
List<QRecord> breakGlass = state().breakGlass();
|
QSession session = QContext.getQSession();
|
||||||
for(QRecord row : policies)
|
for(QRecord row : policies)
|
||||||
{
|
{
|
||||||
String permission = row.getValueString("permission_name");
|
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")))
|
List<String> breaks = activeBreakGlass().stream().map(row -> row.getValueString("id")).sorted().toList();
|
||||||
.filter(row -> row.getValueInstant("expires_at") != null && row.getValueInstant("expires_at").isAfter(now)).toList();
|
String payload = JsonUtils.toJson(List.of(userId(), relevant, runtime.policyVersion(), runtime.classificationVersion(), breaks,
|
||||||
List<String> breaks = activeBreaks.stream().map(row -> row.getValueString("id")).sorted().toList();
|
Objects.requireNonNullElse(SecuredDataMetadata.channel(session), "")));
|
||||||
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
|
||||||
{
|
{
|
||||||
String value = Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(payload.getBytes(StandardCharsets.UTF_8)));
|
return (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,21 +21,9 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -46,33 +34,9 @@ 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();
|
||||||
@@ -80,10 +44,6 @@ 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();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +53,6 @@ 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);
|
||||||
@@ -178,7 +137,6 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
|||||||
public void invalidateClassifications()
|
public void invalidateClassifications()
|
||||||
{
|
{
|
||||||
classificationVersion.incrementAndGet();
|
classificationVersion.incrementAndGet();
|
||||||
announce(CLASSIFICATION_CACHE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -189,102 +147,6 @@ 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -295,13 +157,11 @@ 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 || tooOld(classificationRowsLoadedAt))
|
if(cachedClassificationVersion != version)
|
||||||
{
|
{
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-6
@@ -238,8 +238,7 @@ 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
|
: SecuredDataTables.POLICY.equals(table) || SecuredDataTables.BREAK_GLASS.equals(table) ? runtime::invalidatePolicies : null;
|
||||||
: 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;
|
||||||
@@ -249,10 +248,6 @@ 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,68 +58,15 @@ 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);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
** The active rules of a table that name this field. Core asks this for every
|
** Fresh rules in the current transaction; edits take effect without a stale cache.
|
||||||
** 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)
|
||||||
{
|
{
|
||||||
SecuredDataRuntime runtime = SecuredDataRuntime.current();
|
return (SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
|
||||||
List<QRecord> rules;
|
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true))
|
||||||
if(runtime == null || readsOwnEdits())
|
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, table)))
|
||||||
{
|
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
|
|||||||
@@ -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