feat: QQQ RC.7 followers provider and secured-data label rules #23

Merged
KofTwentyTwo merged 1 commits from feature/qqq-rc7 into develop 2026-10-14 21:33:34 +00:00
17 changed files with 485 additions and 35 deletions

No files matched your search

+6 -1
View File
@@ -6,6 +6,11 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Changed
- Upgrade the default QQQ BOM to `4.1.0-RC.7` from Maven Central.
- Register the core followers provider for alerts and other qbits, with event preferences, future-mute exclusion, system reads scoped to one record, and caller-owned recipient access checks.
- Cache favorite/recent record labels only for FULL field access. Resolve comment/share notification labels per recipient with a table-label-and-id fallback. Use the typed core label-access API for email labels as well.
- Verify RC.7 timeline audit masking and HIDDEN-field omission through enforced viewer queries, including canaries in values and messages.
- Switch the optional qbit-email integration to 0.1.0-RC.2 and its published `com.kingsrook.qbits.email.api` package, preserving templates, categories, idempotency keys, linked records and enqueue-status handling. Narrow the email package allowance to the public API.
- Email through qbit-email 0.1.0-RC.1: new optional dependency and `EmailCollaborationNotifier` (templates `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder`), the default notifier when qbit-email is produced in the instance and no `notifier` is configured; `NoOpNotifier` otherwise. One NOTIFICATION message per recipient, no comment bodies, record labels only when field access allows. New `recipientEmailResolver` config hook; `CollaborationNotification` gains optional `sourceId` and `actorUserId`.
@@ -38,7 +43,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
- Adopt core feature overrides, record capabilities and implied WRITE locks, reference metadata, mirrored locks, after-commit cleanup and summaries. Core registers feature permissions in the policy editor.
- Replace `OtherUserAccess`/`UserSessionSupplier` with `CollaborationRecordAccessChecker` and core `UserSessionProviderInterface`; configure `withUserSessionProvider`.
- Remove `ParentTableRestriction` and `TagFilterTranslator`; runtime `collaborationTags` filtering/search uses core subqueries. Retain the unpinned visibility fallback still missing from core.
- Publish identifier-only realtime hints after commit. Selectable Tags output and the optional followers SPI await missing core hooks.
- Publish identifier-only realtime hints after commit. Selectable Tags output awaits a core hook; the followers SPI is adopted above.
- Build against locally installed QQQ `4.1.0-SNAPSHOT` until RC.6 is released; `qqq-middleware-javalin` is a compile dependency. Object storage remains pinned to `0.1.0-RC.2`.
+23 -6
View File
@@ -11,16 +11,33 @@ Comment parents list the selected thread's root and replies. Feature-setting sco
## Requirements
- Java 21.
- QQQ `4.1.0-SNAPSHOT` installed from develop, including UI API extension point #1128. The build will move to `4.1.0-RC.6` once released; middleware Javalin is a compile dependency.
- QQQ `4.1.0-RC.7` from Maven Central; middleware Javalin is a compile dependency.
- A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use the memory backend.
- Attachments require `qbit-object-storage:0.1.0-RC.2` (a pinned dependency), installed with a PRIVATE bucket alias matching `attachmentsBucketName`. Configure its connection and credentials in the host; this qbit does not provision buckets.
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-SNAPSHOT`, targeting RC.6.
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-RC.7`.
## Followers and secured data
The instance registers a core `RecordFollowersProviderInterface` backed by
`collaboration_follow`, so alerts and other qbits can use `RecordFollowers.get`.
CHANGE and ALERT select `notifyOnChange`; COMMENT selects `notifyOnComment`.
Future mutes are excluded, and disabling FOLLOW returns no subscribers. The
provider reads only the requested table/record as the system. Consumers must
check each recipient's record access before sending data.
Favorites and recents cache a record label only when every label field is FULL
for the current session; otherwise the stored label is null. Lists continue to
re-read labels as the viewer. Comment and share notification titles resolve
labels in each recipient's session and fall back to the table label and id
(for example, `Order #1042`) when label access is restricted or unavailable.
Timeline audit reads use core's enforced query boundary: MASKED values are
masked, HIDDEN details are omitted, and restricted audit messages are cleared.
Comment bodies remain user content; realtime field-name filtering is handled by core.
## Demo server
`CollaborationDevServer` is a disposable, memory-backed Sales CRM for trying the
Next dashboard and taking screenshots. It needs Java 21 and the locally installed
QQQ `4.1.0-SNAPSHOT` from develop. No database, Docker or MinIO is needed. It binds
Next dashboard and taking screenshots. It needs Java 21 and QQQ `4.1.0-RC.7` from Maven Central. No database, Docker or MinIO is needed. It binds
to `127.0.0.1`; stopping the JVM discards edits and uploaded attachments.
Build the Next static export in `qqq-frontend-next`:
@@ -563,7 +580,7 @@ host integrations as other collaboration tables.
## Core contracts and completed UI API
The qbit targets QQQ RC.6 using the locally installed `4.1.0-SNAPSHOT`. Defaults enable FAVORITES, RECENTS and TIMELINE for eligible host tables; all other features start off. Configure before producing metadata:
The qbit uses QQQ `4.1.0-RC.7` from Maven Central. Defaults enable FAVORITES, RECENTS and TIMELINE for eligible host tables; all other features start off. Configure before producing metadata:
```java
config.withDefaultFeatures(RecordFeature.FAVORITES, RecordFeature.RECENTS, RecordFeature.TIMELINE)
@@ -595,7 +612,7 @@ New routes, all under `/qqq/v1/qbit/collaboration`:
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension stays at unreleased **1.0.0**; contract tooling accepts these additions without a bump.
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. The optional followers provider is skipped because `RecordFollowersProviderInterface` is absent from the installed jar. Selectable Tags values remain unavailable because the overlay has no output hook.
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. `CollaborationFollowersProvider` supplies the core followers SPI for alerts and other consumers. Selectable Tags values remain unavailable because the overlay has no output hook.
## Custom fields (phase f)
+2 -2
View File
@@ -196,14 +196,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -68,6 +68,7 @@ public final class CoreCollaboration
{
config.applyFeatures(instance);
var features = RecordFeaturesMetaData.ofOrWithNew(instance);
features.withFollowersProvider(new QCodeReference(com.kingsrook.qbits.collaboration.follow.CollaborationFollowersProvider.class));
features.withOverrideProvider(new QCodeReference(FeatureSettingsProvider.class));
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.tags.TagsMetaDataProvider.class));
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.CustomFieldsProvider.class));
@@ -35,6 +35,7 @@ import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -55,7 +56,7 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Resolves targets only with released RC.5 APIs. Each list batches record label
** Resolves targets through core permission and field-access APIs. Each list batches record label
** refreshes by table, preserving table permissions and record security locks.
** Next accepts the flat /app/name/ routes, including dotted process names.
*******************************************************************************/
@@ -131,6 +132,17 @@ public final class TargetResolver
/*******************************************************************************
** Persist record labels only when all label fields are FULL for this session.
*******************************************************************************/
public static String cacheableLabel(FavoriteEntry target)
{
return ("RECORD".equals(target.targetType())
&& !FieldAccessHelper.isRecordLabelFullAccess(QContext.getQInstance().getTable(target.targetName())) ? null : target.label());
}
/*******************************************************************************
** One QueryAction per referenced record table; no cached labels escape.
*******************************************************************************/
@@ -288,7 +288,7 @@ public class CommentsService
new FollowService().ensureFollower(user, table, id, "MENTIONED");
if(!user.equals(PersonalRecords.currentUserId()))
{
notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment", mention.getValueInteger("id"));
notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment on ", mention.getValueInteger("id"));
notified.add(user);
}
}
@@ -307,7 +307,7 @@ public class CommentsService
if(!author.equals(follower.userId()) && !mentioned.contains(follower.userId()) && Boolean.TRUE.equals(follower.notifyOnComment())
&& (follower.mutedUntil() == null || !follower.mutedUntil().isAfter(Instant.now())))
{
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on a followed record", null);
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on ", null);
}
}
}
@@ -317,7 +317,8 @@ public class CommentsService
*******************************************************************************/
private void notify(QRecord row, String user, CollaborationNotification.Category category, String title, Integer mentionId) throws QException
{
Notifications.send(new CollaborationNotification(user, title, row.getValueString("body"), category,
Notifications.send(new CollaborationNotification(user, title
+ new CollaborationRecordAccessChecker().notificationLabel(user, row.getValueString("tableName"), row.getValueString("recordId")), row.getValueString("body"), category,
row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId")))
.withEvent(mentionId == null ? row.getValue("id") : mentionId, PersonalRecords.currentUserId()),
mentionId == null ? null : () -> CollaborationRecords.update(CollaborationTables.MENTION,
@@ -70,7 +70,7 @@ public final class FavoritesService
.withOrderBy(new QFilterOrderBy("sortOrder", false)).withLimit(1));
Integer order = last.isEmpty() ? 0 : Math.addExact(last.getFirst().getValueInteger("sortOrder"), 1);
QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName)
.withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("sortOrder", order);
.withValue("recordId", target.recordId()).withValue("label", TargetResolver.cacheableLabel(target)).withValue("sortOrder", order);
try
{
PersonalRecords.insert(CollaborationTables.FAVORITE, row);
@@ -0,0 +1,70 @@
/*
* 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.collaboration.follow;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowersProviderInterface;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.collaboration.FollowEvent;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Core event subscribers; consumers must check each recipient's record access.
*******************************************************************************/
public class CollaborationFollowersProvider implements RecordFollowersProviderInterface
{
/*******************************************************************************
** Read only this parent's subscriptions as the system, independent of the caller.
*******************************************************************************/
@Override
public List<RecordFollower> getFollowers(QInstance instance, RecordReference reference, FollowEvent event) throws QException
{
List<RecordFollower> followers = new ArrayList<>();
QContext.withTemporaryContext(new CapturedContext(instance, new QSystemUserSession(instance)), () ->
{
Instant now = Instant.now();
String preference = event == FollowEvent.COMMENT ? "notifyOnComment" : "notifyOnChange";
for(QRecord row : CollaborationRecords.query(CollaborationTables.FOLLOW,
CollaborationRecords.parent(reference.tableName(), reference.primaryKey().toString())))
{
if(Boolean.TRUE.equals(row.getValueBoolean(preference))
&& (row.getValueInstant("mutedUntil") == null || !row.getValueInstant("mutedUntil").isAfter(now)))
{
followers.add(new RecordFollower().withUserId(row.getValueString("userId"))
.withNotifyOnChange(row.getValueBoolean("notifyOnChange")).withNotifyOnComment(row.getValueBoolean("notifyOnComment"))
.withReason(row.getValueString("reason")));
}
}
});
return (followers);
}
}
@@ -70,7 +70,7 @@ public final class RecentsService
if(existing.isEmpty())
{
QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName)
.withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("lastViewedAt", now).withValue("viewCount", 1);
.withValue("recordId", target.recordId()).withValue("label", TargetResolver.cacheableLabel(target)).withValue("lastViewedAt", now).withValue("viewCount", 1);
try
{
PersonalRecords.insert(CollaborationTables.RECENT, row);
@@ -92,7 +92,7 @@ public final class RecentsService
return;
}
PersonalRecords.update(CollaborationTables.RECENT, List.of(new QRecord().withValue("id", row.getValue("id"))
.withValue("lastViewedAt", now).withValue("viewCount", Math.addExact(row.getValueInteger("viewCount"), 1)).withValue("label", target.label())));
.withValue("lastViewedAt", now).withValue("viewCount", Math.addExact(row.getValueInteger("viewCount"), 1)).withValue("label", TargetResolver.cacheableLabel(target))));
}
@@ -27,6 +27,7 @@ import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -34,7 +35,6 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
@@ -90,8 +90,7 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
/*******************************************************************************
** The record label as the recipient may see it: empty unless a session for the
** recipient exists, the record is readable, and every label field is fully
** accessible (FieldAccessHelper.isRecordLabelFullAccess, absent in older QQQ
** versions, which means no label).
** accessible under the recipient session's field-access policy.
*******************************************************************************/
public Optional<String> recordLabel(String userId, String table, Serializable recordId) throws QException
{
@@ -111,7 +110,7 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
try
{
ParentRecordAccess.requireVisibleParent(table, recordId);
if(labelFullyAccessible(table))
if(FieldAccessHelper.isRecordLabelFullAccess(QContext.getQInstance().getTable(table)))
{
GetInput input = new GetInput(table).withPrimaryKey(recordId).withShouldGenerateDisplayValues(true);
QRecord record = new GetAction().executeForRecord(input);
@@ -127,18 +126,10 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
}
/*******************************************************************************
** Reflective guard: the helper only exists in QQQ versions with secured data.
** Notification titles fall back to public table metadata and the record id.
*******************************************************************************/
private static boolean labelFullyAccessible(String table)
public String notificationLabel(String userId, String table, Serializable recordId) throws QException
{
try
{
Class<?> helper = Class.forName("com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper");
return (Boolean.TRUE.equals(helper.getMethod("isRecordLabelFullAccess", QTableMetaData.class).invoke(null, QContext.getQInstance().getTable(table))));
}
catch(ReflectiveOperationException | LinkageError e)
{
return (false);
}
return (recordLabel(userId, table, recordId).orElse(QContext.getQInstance().getTable(table).getLabel() + " #" + recordId));
}
}
@@ -97,7 +97,8 @@ public class SharingService
.withValue("recipientCouldRead", recipient.canRead()).withValue("sentAt", Instant.now()).withValue("parentDeleted", false));
if(Boolean.TRUE.equals(recipient.canRead()))
{
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared " + link.label() + " with you", message,
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared "
+ new CollaborationRecordAccessChecker().notificationLabel(recipient.userId(), table, id) + " with you", message,
CollaborationNotification.Category.SHARE, table, id, link.url()).withEvent(row.getValue("id"), from));
new FollowService().ensureFollower(recipient.userId(), table, id, "SHARED");
}
@@ -179,8 +179,9 @@ public class CustomFieldsTest extends PersonalServiceFixture
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(CollaborationTables.FIELD_DEFINITION).withPrimaryKeys(List.of(row.getValue("id"))))).hasMessageContaining("archive");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("name", "renamed"), row)).hasMessageContaining("cannot change");
assertThatThrownBy(() -> FieldDefinitions.options(new QRecord().withValue("options", "{}"))).hasMessageContaining("options");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("classification", "BAD"), row)).hasMessageContaining("classification");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("minValue", 5).withValue("maxValue", 1), row)).hasMessageContaining("Minimum");
QRecord published = FieldDefinitions.get(row.getValue("id"));
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(published).withValue("classification", "BAD"), published)).hasMessageContaining("classification");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(published).withValue("minValue", 5).withValue("maxValue", 1), published)).hasMessageContaining("Minimum");
}
/*******************************************************************************
@@ -0,0 +1,109 @@
/*
* 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.collaboration;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowers;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Exercise the installed provider through core's public event-subscriber facade.
*******************************************************************************/
class FollowersProviderTest extends PersonalServiceFixture
{
/*******************************************************************************
** Preferences, mutes and parent scope apply even for callers without access.
*******************************************************************************/
@Test
void resolvesEventSubscribersAsSystemWithoutFilteringRecipientAccess() throws Exception
{
seed("change", "document", "1", true, false, null, false);
seed("comment", "document", "1", false, true, null, false);
seed("both", "document", "1", true, true, Instant.now().minusSeconds(60), false);
seed("muted", "document", "1", true, true, Instant.now().plusSeconds(3600), false);
seed("neither", "document", "1", false, false, null, false);
seed("other-record", "document", "2", true, true, null, false);
seed("other-table", "other", "1", true, true, null, false);
seed("deleted", "document", "1", true, true, null, true);
RecordReference reference = new RecordReference("document", 1);
for(QSession session : List.of(new QSession(), new QSystemUserSession(instance)))
{
QContext.init(instance, session);
for(FollowEvent event : List.of(FollowEvent.ALERT, FollowEvent.CHANGE))
{
assertThat(RecordFollowers.get(instance, reference, event)).extracting(RecordFollower::getUserId)
.containsExactlyInAnyOrder("change", "both");
}
assertThat(RecordFollowers.get(instance, reference, FollowEvent.COMMENT)).extracting(RecordFollower::getUserId)
.containsExactlyInAnyOrder("comment", "both");
assertThat(RecordFollowers.get(instance, reference, FollowEvent.CHANGE)).filteredOn(f -> f.getUserId().equals("change"))
.singleElement().satisfies(f ->
{
assertThat(f.getNotifyOnChange()).isTrue();
assertThat(f.getNotifyOnComment()).isFalse();
assertThat(f.getReason()).isEqualTo("MANUAL");
});
assertThat(QContext.getQSession()).isSameAs(session);
}
}
/*******************************************************************************
** Existing subscriptions do not override the host's FOLLOW feature switch.
*******************************************************************************/
@Test
void disabledFollowReturnsEmpty() throws Exception
{
seed("follower", "document", "1", true, true, null, false);
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document")).withEnabled(RecordFeature.FOLLOW, false);
RuntimeMetaDataOverlay.refresh(instance);
for(FollowEvent event : FollowEvent.values())
{
assertThat(RecordFollowers.get(instance, new RecordReference("document", 1), event)).isEmpty();
}
}
/*******************************************************************************
** Seed persisted preferences directly, including subscriptions lacking access.
*******************************************************************************/
private void seed(String user, String table, String id, Boolean change, Boolean comment, Instant muted, Boolean deleted) throws Exception
{
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.FOLLOW).withRecords(List.of(new QRecord()
.withValue("userId", user).withValue("tableName", table).withValue("recordId", id).withValue("reason", "MANUAL")
.withValue("notifyOnChange", change).withValue("notifyOnComment", comment).withValue("mutedUntil", muted)
.withValue("parentDeleted", deleted))), true);
}
}
@@ -0,0 +1,159 @@
/*
* 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.collaboration;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.comments.CommentsService;
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotifier;
import com.kingsrook.qbits.collaboration.notify.RecordingNotifier;
import com.kingsrook.qbits.collaboration.recents.RecentsService;
import com.kingsrook.qbits.collaboration.sharing.SharingService;
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Cached and delivered labels respect the session that will receive the data.
*******************************************************************************/
class SecuredLabelsTest extends PersonalServiceFixture
{
private static RecordingNotifier recorder;
/*******************************************************************************
** Non-FULL labels are never cached; lists always re-read as the current viewer.
*******************************************************************************/
@Test
void cachesOnlyFullAccessLabelsAndRefreshesForViewer() throws Exception
{
for(FieldSecurityLock.Behavior behavior : List.of(FieldSecurityLock.Behavior.MASK, FieldSecurityLock.Behavior.DENY, FieldSecurityLock.Behavior.REVEAL))
{
restrictLabel(behavior);
FavoritesService.star("RECORD", "document", 1);
RecentsService.recordView("RECORD", "document", 1);
for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT))
{
assertThat(PersonalRecords.query(table, PersonalRecords.ownFilter())).singleElement()
.satisfies(row -> assertThat(row.getValueString("label")).isNull());
}
assertThat(FavoritesService.list()).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
assertThat(RecentsService.list(50)).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
FavoritesService.unstar("RECORD", "document", 1);
PersonalRecords.delete(CollaborationTables.RECENT, PersonalRecords.query(CollaborationTables.RECENT, PersonalRecords.ownFilter()).stream().map(row -> row.getValue("id")).toList());
}
QContext.getQSession().withSecurityKeyValue("sensitive", "full");
FavoritesService.star("RECORD", "document", 1);
RecentsService.recordView("RECORD", "document", 1);
for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT))
{
assertThat(PersonalRecords.query(table, PersonalRecords.ownFilter())).singleElement()
.satisfies(row -> assertThat(row.getValueString("label")).isEqualTo("First document"));
}
login("alice");
assertThat(FavoritesService.list()).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
assertThat(RecentsService.list(50)).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
FavoritesService.star("TABLE", "document", null);
assertThat(PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.targetFilter("TABLE", "document", null)))
.singleElement().satisfies(row -> assertThat(row.getValueString("label")).isEqualTo("Documents"));
}
/*******************************************************************************
** The sender's FULL label cannot leak to recipients with MASKED or HIDDEN access.
*******************************************************************************/
@Test
void notificationTitlesResolveLabelsPerRecipient() throws Exception
{
for(FieldSecurityLock.Behavior behavior : List.of(FieldSecurityLock.Behavior.MASK, FieldSecurityLock.Behavior.DENY))
{
restrictLabel(behavior);
QContext.getQSession().withSecurityKeyValue("sensitive", "full");
recorder = new RecordingNotifier();
config.withNotifier(new QCodeReference(TestNotifier.class)).withUserSessionProvider(new QCodeReference(TestSessions.class));
new SharingService().share("document", 1, List.of("restricted", "full"), "Shared user content");
new CommentsService().post("document", 1, "@[Restricted](user:restricted) @[Full](user:full)", null, null);
assertThat(recorder.notifications()).hasSize(4);
assertThat(recorder.notifications()).filteredOn(n -> n.recipientUserId().equals("restricted")).hasSize(2)
.allSatisfy(n -> assertThat(n.title()).contains("Documents #1").doesNotContain("First document"));
assertThat(recorder.notifications()).filteredOn(n -> n.recipientUserId().equals("full")).hasSize(2)
.allSatisfy(n -> assertThat(n.title()).contains("First document"));
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo("alice");
}
}
/*******************************************************************************
** Field-lock override grants FULL to selected sessions only.
*******************************************************************************/
private void restrictLabel(FieldSecurityLock.Behavior behavior)
{
if(!instance.getSecurityKeyTypes().containsKey("sensitive"))
{
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
}
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(behavior).withOverrideValues(List.of("full")));
}
/*******************************************************************************
** Recipients share record access but have different label access.
*******************************************************************************/
public static class TestSessions implements UserSessionProviderInterface
{
/*******************************************************************************
** Build independent recipient sessions for real core field checks.
*******************************************************************************/
@Override
public Optional<QSession> sessionForUser(String userId)
{
QSession session = new QSession().withUser(new QUser().withIdReference(userId)).withPermissions("document.read")
.withSecurityKeyValue("tenant", "a");
if(userId.equals("full"))
{
session.withSecurityKeyValue("sensitive", "full");
}
return (Optional.of(session));
}
}
/*******************************************************************************
** Capture notifications before any external transport.
*******************************************************************************/
public static class TestNotifier implements CollaborationNotifier
{
/*******************************************************************************
** Preserve the payload for assertions.
*******************************************************************************/
@Override
public void notify(CollaborationNotification notification)
{
recorder.notify(notification);
}
}
}
@@ -32,6 +32,10 @@ import com.kingsrook.qbits.collaboration.timeline.TimelinePage;
import com.kingsrook.qbits.collaboration.timeline.TimelineService;
import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMetaDataPersonalizerInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldMaskStyle;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldValueMasker;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.metadata.personalization.TableMetaDataPersonalizerInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
@@ -182,6 +186,40 @@ public class TimelineServiceTest extends PersonalServiceFixture
assertThat(timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items()).isEmpty();
}
/*******************************************************************************
** RC.7 QueryAction masks audit values and removes HIDDEN detail rows as the viewer.
*******************************************************************************/
@Test
void fieldTiersProtectTimelineValuesAndMessages() throws Exception
{
String canary = "TIMELINE-SECRET-CANARY";
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK).withMaskStyle(FieldMaskStyle.FULL));
instance.getTable("document").getField("tenant").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.DENY));
seedAudit(4, canary, 100);
for(String field : List.of("title", "tenant"))
{
seed("auditDetail", new QRecord().withValue("id", field.equals("title") ? 10L : 11L).withValue("auditId", 4L)
.withValue("fieldName", field).withValue("oldValue", canary).withValue("newValue", canary).withValue("message", canary));
}
TimelinePage page = timeline.timeline("document", 1, null, 50, Set.of("AUDIT"));
assertThat(page.items()).filteredOn(item -> item.id().equals(4L)).singleElement().satisfies(item ->
{
assertThat(item.summary()).isNull();
assertThat(item.details()).singleElement().satisfies(detail ->
{
assertThat(detail.fieldName()).isEqualTo("title");
assertThat(detail.oldValue()).isEqualTo(FieldValueMasker.MASK);
assertThat(detail.newValue()).isEqualTo(FieldValueMasker.MASK);
assertThat(detail.message()).isNull();
});
});
assertThat(page.items().stream().flatMap(item -> item.details().stream())).noneMatch(detail -> "tenant".equals(detail.fieldName()));
assertThat(page.toString()).doesNotContain(canary, "Old", "New", "sensitive old", "sensitive new");
}
/*******************************************************************************
** Real personalizer boundary, like core's audit screen.
*******************************************************************************/
@@ -43,6 +43,8 @@ import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderI
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
@@ -159,7 +161,7 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
assertThat(message.getValueString("linkedTableName")).isEqualTo("document");
assertThat(message.getValueString("linkedRecordId")).isEqualTo("1");
assertThat(message.getValueString("senderQbit")).isEqualTo("qbit-collaboration");
assertThat(message.getValueString("subject")).contains("alice mentioned you on a record in Documents");
assertThat(message.getValueString("subject")).contains("alice mentioned you on First document");
assertThat(message.getValueString("subject") + message.getValueString("textBody") + message.getValueString("htmlBody")).doesNotContain("secret comment body");
assertThat(message.getValueString("htmlBody")).contains("https://app.example.org/app/document/1");
});
@@ -175,6 +177,27 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
/*******************************************************************************
** Restricted record labels never enter email subjects or rendered bodies.
*******************************************************************************/
@Test
void maskedLabelUsesGenericEmailText() throws Exception
{
withEmail();
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK));
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
assertThat(messages()).singleElement().satisfies(message ->
{
assertThat(message.getValueString("subject")).contains("alice mentioned you on a record in Documents");
assertThat(message.getValueString("subject") + message.getValueString("textBody") + message.getValueString("htmlBody"))
.doesNotContain("First document", "secret comment body");
});
}
/*******************************************************************************
** Each recipient gets their own message; the share and task kinds use their
** own templates and key kinds; follow digests send no email.
@@ -52,6 +52,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
@@ -114,6 +116,26 @@ class RecentsServiceTest extends PersonalServiceFixture
/*******************************************************************************
** A refreshed recent discards its old cached label after access is restricted.
*******************************************************************************/
@Test
void refreshClearsLabelWhenFieldAccessIsRestricted() throws Exception
{
at(START);
RecentsService.recordView("RECORD", "document", 1);
assertThat(rows().getFirst().getValueString("label")).isEqualTo("First document");
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK));
at(START.plusSeconds(60));
RecentsService.recordView("RECORD", "document", 1);
assertThat(rows().getFirst().getValueString("label")).isNull();
assertThat(rows().getFirst().getValueInteger("viewCount")).isEqualTo(2);
}
/*******************************************************************************
** Null ids also upsert; visibility is checked even inside the throttle window.
*******************************************************************************/