diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5178c..a085dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index 42aff20..eaf8324 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/pom.xml b/pom.xml index db188c2..030e491 100644 --- a/pom.xml +++ b/pom.xml @@ -196,14 +196,14 @@ - + qqq-snapshot true - 4.1.0-RC.6 + 4.1.0-RC.7 diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java b/src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java index 39b6f98..18d62c7 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java @@ -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)); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/TargetResolver.java b/src/main/java/com/kingsrook/qbits/collaboration/TargetResolver.java index 460f050..904a838 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/TargetResolver.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/TargetResolver.java @@ -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. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java index 9ac58e8..887eb19 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java @@ -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, diff --git a/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java b/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java index 39a0154..6f696e5 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java @@ -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); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/follow/CollaborationFollowersProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/follow/CollaborationFollowersProvider.java new file mode 100644 index 0000000..bbe6123 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/follow/CollaborationFollowersProvider.java @@ -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 getFollowers(QInstance instance, RecordReference reference, FollowEvent event) throws QException + { + List 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/recents/RecentsService.java b/src/main/java/com/kingsrook/qbits/collaboration/recents/RecentsService.java index 19afa23..bab0342 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/recents/RecentsService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/recents/RecentsService.java @@ -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)))); } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRecordAccessChecker.java b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRecordAccessChecker.java index 0f290f2..9761f08 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRecordAccessChecker.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRecordAccessChecker.java @@ -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 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)); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java b/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java index 0813f62..6ea9a23 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java @@ -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"); } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java index d18fb5c..2fde99c 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java @@ -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"); } /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/collaboration/FollowersProviderTest.java b/src/test/java/com/kingsrook/qbits/collaboration/FollowersProviderTest.java new file mode 100644 index 0000000..4d7ab6b --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/FollowersProviderTest.java @@ -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); + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/SecuredLabelsTest.java b/src/test/java/com/kingsrook/qbits/collaboration/SecuredLabelsTest.java new file mode 100644 index 0000000..92c7440 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/SecuredLabelsTest.java @@ -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 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); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java index 37b219d..198ccec 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java @@ -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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java b/src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java index 114f421..545433b 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java @@ -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. diff --git a/src/test/java/com/kingsrook/qbits/collaboration/recents/RecentsServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/recents/RecentsServiceTest.java index cc9e342..7d7bf91 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/recents/RecentsServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/recents/RecentsServiceTest.java @@ -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. *******************************************************************************/