feat: comment read/write auth policies in BE & FE (#4368)

* webhooks perm minor fix

* tryna get fileimport service to work

* new comment policies - shared

* BE done?

* checks implemented in FE

* lint fix

* tests fix

* readme fix
This commit is contained in:
Kristaps Fabians Geikins
2025-04-10 15:14:34 +03:00
committed by GitHub
parent 41e7daa60d
commit b6c21fd506
63 changed files with 2588 additions and 431 deletions
+2
View File
@@ -68,6 +68,8 @@ minio-data/
postgres-data/
redis-data/
packages/fileimport-service/src/ifc-dotnet/output
.tshy-build
obj/
bin/
+32
View File
@@ -11,3 +11,35 @@ The File Import service can parse either STL, OBJ, or IFC files using external p
The parsers are responsible for extracting the necessary data from the files and storing it in the database. They are also responsible for creating a new Speckle model if necessary.
The service is then responsible for updating the status of the `file_uploads` table, and for posting a Postgres notification.
## Dev setup
### Building/Running the .NET importer
Requirements:
- Ubuntu 24+
Do this on Ubuntu/OSX to install dotnet:
```bash
# Add microsoft package repo
sudo apt update && sudo apt install -y wget apt-transport-https
wget https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
# Install dotnet sdk 8
sudo apt update
sudo apt install -y dotnet-sdk-8.0
# Verify version
dotnet --version
```
Do this to build:
```bash
cd ./packages/fileimport-service/src/ifc-dotnet
dotnet publish ifc-converter.csproj -c Release -o output/
```
@@ -16,6 +16,7 @@ import { logger } from '@/observability/logging.js'
import { Nullable, Scopes, wait } from '@speckle/shared'
import { Knex } from 'knex'
import { Logger } from 'pino'
import { getIfcDllPath, useLegacyIfcImporter } from '@/controller/helpers/env.js'
const HEALTHCHECK_FILE_PATH = '/tmp/last_successful_query'
@@ -153,7 +154,10 @@ async function doTask(
taskLogger.info('Triggering importer for {fileType}')
if (info.fileType.toLowerCase() === 'ifc') {
if (info.fileName.toLowerCase().endsWith('.legacyimporter.ifc')) {
if (
info.fileName.toLowerCase().endsWith('.legacyimporter.ifc') ||
useLegacyIfcImporter()
) {
await runProcessWithTimeout(
taskLogger,
process.env['NODE_BINARY_PATH'] || 'node',
@@ -181,8 +185,7 @@ async function doTask(
taskLogger,
process.env['DOTNET_BINARY_PATH'] || 'dotnet',
[
process.env['IFC_DOTNET_DLL_PATH'] ||
'/speckle-server/packages/fileimport-service/src/ifc-dotnet/ifc-converter.dll',
getIfcDllPath(),
TMP_FILE_PATH,
TMP_RESULTS_PATH,
info.streamId,
@@ -0,0 +1,61 @@
import path from 'node:path'
import url from 'node:url'
import file from 'node:fs'
export const isDevEnv = () => {
return process.env.NODE_ENV === 'development'
}
export const isTestEnv = () => {
return process.env.NODE_ENV === 'test'
}
export const isDevOrTestEnv = () => isDevEnv() || isTestEnv()
export const useLegacyIfcImporter = () => {
return ['true', '1'].includes(process.env.USE_LEGACY_IFC_IMPORTER || 'false')
}
export const getPackageRootDirPath = () => {
const __filename = url.fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
let root = path.resolve(__dirname, '../../../')
if (root.endsWith('dist')) {
// Resolved path may differ depending on whether running from dist or src (w/ ts-node)
root = path.resolve(root, '../')
}
return root
}
let cachedIfcDllPath: string | undefined = undefined
export const getIfcDllPath = () => {
if (cachedIfcDllPath) return cachedIfcDllPath
const absolutePath = process.env['IFC_DOTNET_DLL_PATH']
if (absolutePath && file.existsSync(absolutePath)) {
cachedIfcDllPath = absolutePath
return absolutePath
}
if (isDevOrTestEnv()) {
const possiblePath = path.resolve(
getPackageRootDirPath(),
'./src/ifc-dotnet/output/ifc-converter.dll'
)
if (file.existsSync(possiblePath)) {
cachedIfcDllPath = absolutePath
return possiblePath
}
}
const fallback =
'/speckle-server/packages/fileimport-service/src/ifc-dotnet/ifc-converter.dll'
if (file.existsSync(fallback)) {
cachedIfcDllPath = fallback
return fallback
}
throw new Error('Could not resolve .NET IFC DLL')
}
@@ -4,7 +4,7 @@ import Observability from '@speckle/shared/dist/commonjs/observability/index.js'
export const logger = Observability.extendLoggerComponent(
Observability.getLogger(
process.env.LOG_LEVEL || 'info',
process.env.LOG_PRETTY === 'true'
process.env.LOG_PRETTY === 'true' && !process.env.FORCE_NO_PRETTY
),
'fileimport-service'
)
@@ -195,7 +195,7 @@ import {
ArrowLeftIcon,
ArrowUpRightIcon
} from '@heroicons/vue/24/outline'
import { ensureError, Roles } from '@speckle/shared'
import { ensureError } from '@speckle/shared'
import type { Nullable } from '@speckle/shared'
import { onKeyDown, useClipboard, useDraggable, onClickOutside } from '@vueuse/core'
import { scrollToBottom } from '~~/lib/common/helpers/dom'
@@ -216,6 +216,18 @@ import { useDisableGlobalTextSelection } from '~~/lib/common/composables/window'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useThreadUtilities } from '~~/lib/viewer/composables/ui'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
import { graphql } from '~/lib/common/generated/gql'
graphql(`
fragment ViewerCommentThreadData on Comment {
id
permissions {
canArchive {
...FullPermissionCheckResult
}
}
}
`)
const emit = defineEmits<{
(e: 'update:modelValue', v: CommentBubbleModel): void
@@ -235,14 +247,9 @@ const { isEmbedEnabled } = useEmbed()
const threadId = computed(() => props.modelValue.id)
const { copy } = useClipboard()
const { activeUser, isLoggedIn } = useActiveUser()
const { isLoggedIn } = useActiveUser()
const archiveComment = useArchiveComment()
const { triggerNotification } = useGlobalToast()
const {
resources: {
response: { project }
}
} = useInjectedViewerState()
const { projectId } = useInjectedViewerState()
const canReply = useCheckViewerCommentingAccess()
@@ -401,10 +408,7 @@ const changeExpanded = async (newVal: boolean) => {
}
const canArchiveOrUnarchive = computed(
() =>
activeUser.value &&
(props.modelValue.author.id === activeUser.value.id ||
project.value?.role === Roles.Stream.Owner)
() => props.modelValue.permissions.canArchive.authorized
)
const toggleCommentResolvedStatus = async () => {
@@ -31,7 +31,6 @@
size="sm"
:icon-left="includeArchived ? CheckCircleIcon : CheckCircleIconOutlined"
text
:disabled="commentThreadsMetadata?.totalArchivedCount === 0"
class="!text-foreground"
@click="includeArchived = includeArchived ? undefined : 'includeArchived'"
>
@@ -133,6 +133,7 @@ type Documents = {
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n": typeof types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\n }\n": typeof types.SettingsWorkspacesSecuritySsoWrapper_WorkspaceFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n }\n": typeof types.ModelPageProjectFragmentDoc,
"\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ViewerCommentThreadDataFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": typeof types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": typeof types.ViewerCommentsListItemFragmentDoc,
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": typeof types.ViewerModelVersionCardItemFragmentDoc,
@@ -329,8 +330,10 @@ type Documents = {
"\n mutation verifyEmail($input: VerifyUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n verify(input: $input)\n }\n }\n }\n": typeof types.VerifyEmailDocument,
"\n fragment EmailFields on UserEmail {\n id\n email\n verified\n primary\n userId\n }\n": typeof types.EmailFieldsFragmentDoc,
"\n query UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n": typeof types.UserEmailsDocument,
"\n fragment UseViewerUserActivityBroadcasting_Project on Project {\n id\n permissions {\n canBroadcastActivity {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseViewerUserActivityBroadcasting_ProjectFragmentDoc,
"\n fragment ViewerCommentBubblesData on Comment {\n id\n viewedAt\n viewerState\n }\n": typeof types.ViewerCommentBubblesDataFragmentDoc,
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n }\n": typeof types.ViewerCommentThreadFragmentDoc,
"\n fragment UseCheckViewerCommentingAccess_Project on Project {\n id\n permissions {\n canCreateComment {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseCheckViewerCommentingAccess_ProjectFragmentDoc,
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n": typeof types.ViewerCommentThreadFragmentDoc,
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": typeof types.ViewerCommentsReplyItemFragmentDoc,
"\n mutation BroadcastViewerUserActivity(\n $projectId: String!\n $resourceIdString: String!\n $message: ViewerUserActivityMessageInput!\n ) {\n broadcastViewerUserActivity(\n projectId: $projectId\n resourceIdString: $resourceIdString\n message: $message\n )\n }\n": typeof types.BroadcastViewerUserActivityDocument,
"\n mutation MarkCommentViewed($input: MarkCommentViewedInput!) {\n commentMutations {\n markViewed(input: $input)\n }\n }\n": typeof types.MarkCommentViewedDocument,
@@ -338,7 +341,7 @@ type Documents = {
"\n mutation CreateCommentReply($input: CreateCommentReplyInput!) {\n commentMutations {\n reply(input: $input) {\n ...ViewerCommentsReplyItem\n }\n }\n }\n": typeof types.CreateCommentReplyDocument,
"\n mutation ArchiveComment($input: ArchiveCommentInput!) {\n commentMutations {\n archive(input: $input)\n }\n }\n": typeof types.ArchiveCommentDocument,
"\n query ProjectViewerResources($projectId: String!, $resourceUrlString: String!) {\n project(id: $projectId) {\n id\n viewerResources(resourceIdString: $resourceUrlString) {\n identifier\n items {\n modelId\n versionId\n objectId\n }\n }\n }\n }\n": typeof types.ProjectViewerResourcesDocument,
"\n query ViewerLoadedResources(\n $projectId: String!\n $modelIds: [String!]!\n $versionIds: [String!]\n ) {\n project(id: $projectId) {\n id\n role\n allowPublicComments\n models(filter: { ids: $modelIds }) {\n totalCount\n items {\n id\n name\n updatedAt\n loadedVersion: versions(\n filter: { priorityIds: $versionIds, priorityIdsOnly: true }\n ) {\n items {\n ...ViewerModelVersionCardItem\n automationsStatus {\n id\n automationRuns {\n ...AutomateViewerPanel_AutomateRun\n }\n }\n }\n }\n versions(limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n ...ProjectPageLatestItemsModels\n ...ModelPageProject\n ...HeaderNavShare_Project\n }\n }\n": typeof types.ViewerLoadedResourcesDocument,
"\n query ViewerLoadedResources(\n $projectId: String!\n $modelIds: [String!]!\n $versionIds: [String!]\n ) {\n project(id: $projectId) {\n id\n role\n allowPublicComments\n models(filter: { ids: $modelIds }) {\n totalCount\n items {\n id\n name\n updatedAt\n loadedVersion: versions(\n filter: { priorityIds: $versionIds, priorityIdsOnly: true }\n ) {\n items {\n ...ViewerModelVersionCardItem\n automationsStatus {\n id\n automationRuns {\n ...AutomateViewerPanel_AutomateRun\n }\n }\n }\n }\n versions(limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n ...ProjectPageLatestItemsModels\n ...ModelPageProject\n ...HeaderNavShare_Project\n ...UseCheckViewerCommentingAccess_Project\n ...UseViewerUserActivityBroadcasting_Project\n }\n }\n": typeof types.ViewerLoadedResourcesDocument,
"\n query ViewerModelVersions(\n $projectId: String!\n $modelId: String!\n $versionsCursor: String\n ) {\n project(id: $projectId) {\n id\n role\n model(id: $modelId) {\n id\n versions(cursor: $versionsCursor, limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n }\n": typeof types.ViewerModelVersionsDocument,
"\n query ViewerDiffVersions(\n $projectId: String!\n $modelId: String!\n $versionAId: String!\n $versionBId: String!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n versionA: version(id: $versionAId) {\n ...ViewerModelVersionCardItem\n }\n versionB: version(id: $versionBId) {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n": typeof types.ViewerDiffVersionsDocument,
"\n query ViewerLoadedThreads(\n $projectId: String!\n $filter: ProjectCommentsFilter!\n $cursor: String\n $limit: Int\n ) {\n project(id: $projectId) {\n id\n commentThreads(filter: $filter, cursor: $cursor, limit: $limit) {\n totalCount\n totalArchivedCount\n items {\n ...ViewerCommentThread\n ...LinkableComment\n }\n }\n }\n }\n": typeof types.ViewerLoadedThreadsDocument,
@@ -407,10 +410,10 @@ type Documents = {
"\n fragment AutomateFunctionPage_AutomateFunction on AutomateFunction {\n id\n name\n description\n logo\n supportedSourceApps\n tags\n ...AutomateFunctionPageHeader_Function\n ...AutomateFunctionPageInfo_AutomateFunction\n ...AutomateAutomationCreateDialog_AutomateFunction\n creator {\n id\n }\n }\n": typeof types.AutomateFunctionPage_AutomateFunctionFragmentDoc,
"\n query AutomateFunctionPage($functionId: ID!) {\n automateFunction(id: $functionId) {\n ...AutomateFunctionPage_AutomateFunction\n }\n activeUser {\n workspaces {\n items {\n ...AutomateFunctionCreateDialog_Workspace\n ...AutomateFunctionEditDialog_Workspace\n }\n }\n }\n }\n": typeof types.AutomateFunctionPageDocument,
"\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n": typeof types.AutomateFunctionPageWorkspaceDocument,
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n }\n": typeof types.ProjectPageProjectFragmentDoc,
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n ...ProjectPageSettingsTab_Project\n }\n": typeof types.ProjectPageProjectFragmentDoc,
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": typeof types.ProjectPageAutomationPage_AutomationFragmentDoc,
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": typeof types.ProjectPageAutomationPage_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageSettingsTab_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageSettingsTab_ProjectFragmentDoc,
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": typeof types.SettingsServerProjects_ProjectCollectionFragmentDoc,
"\n fragment SettingsServerProjects_User on User {\n permissions {\n canCreatePersonalProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.SettingsServerProjects_UserFragmentDoc,
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": typeof types.SettingsServerRegionsDocument,
@@ -542,6 +545,7 @@ const documents: Documents = {
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\n }\n": types.SettingsWorkspacesSecuritySsoWrapper_WorkspaceFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n }\n": types.ModelPageProjectFragmentDoc,
"\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ViewerCommentThreadDataFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": types.ViewerCommentsListItemFragmentDoc,
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": types.ViewerModelVersionCardItemFragmentDoc,
@@ -738,8 +742,10 @@ const documents: Documents = {
"\n mutation verifyEmail($input: VerifyUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n verify(input: $input)\n }\n }\n }\n": types.VerifyEmailDocument,
"\n fragment EmailFields on UserEmail {\n id\n email\n verified\n primary\n userId\n }\n": types.EmailFieldsFragmentDoc,
"\n query UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n": types.UserEmailsDocument,
"\n fragment UseViewerUserActivityBroadcasting_Project on Project {\n id\n permissions {\n canBroadcastActivity {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseViewerUserActivityBroadcasting_ProjectFragmentDoc,
"\n fragment ViewerCommentBubblesData on Comment {\n id\n viewedAt\n viewerState\n }\n": types.ViewerCommentBubblesDataFragmentDoc,
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n }\n": types.ViewerCommentThreadFragmentDoc,
"\n fragment UseCheckViewerCommentingAccess_Project on Project {\n id\n permissions {\n canCreateComment {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseCheckViewerCommentingAccess_ProjectFragmentDoc,
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n": types.ViewerCommentThreadFragmentDoc,
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": types.ViewerCommentsReplyItemFragmentDoc,
"\n mutation BroadcastViewerUserActivity(\n $projectId: String!\n $resourceIdString: String!\n $message: ViewerUserActivityMessageInput!\n ) {\n broadcastViewerUserActivity(\n projectId: $projectId\n resourceIdString: $resourceIdString\n message: $message\n )\n }\n": types.BroadcastViewerUserActivityDocument,
"\n mutation MarkCommentViewed($input: MarkCommentViewedInput!) {\n commentMutations {\n markViewed(input: $input)\n }\n }\n": types.MarkCommentViewedDocument,
@@ -747,7 +753,7 @@ const documents: Documents = {
"\n mutation CreateCommentReply($input: CreateCommentReplyInput!) {\n commentMutations {\n reply(input: $input) {\n ...ViewerCommentsReplyItem\n }\n }\n }\n": types.CreateCommentReplyDocument,
"\n mutation ArchiveComment($input: ArchiveCommentInput!) {\n commentMutations {\n archive(input: $input)\n }\n }\n": types.ArchiveCommentDocument,
"\n query ProjectViewerResources($projectId: String!, $resourceUrlString: String!) {\n project(id: $projectId) {\n id\n viewerResources(resourceIdString: $resourceUrlString) {\n identifier\n items {\n modelId\n versionId\n objectId\n }\n }\n }\n }\n": types.ProjectViewerResourcesDocument,
"\n query ViewerLoadedResources(\n $projectId: String!\n $modelIds: [String!]!\n $versionIds: [String!]\n ) {\n project(id: $projectId) {\n id\n role\n allowPublicComments\n models(filter: { ids: $modelIds }) {\n totalCount\n items {\n id\n name\n updatedAt\n loadedVersion: versions(\n filter: { priorityIds: $versionIds, priorityIdsOnly: true }\n ) {\n items {\n ...ViewerModelVersionCardItem\n automationsStatus {\n id\n automationRuns {\n ...AutomateViewerPanel_AutomateRun\n }\n }\n }\n }\n versions(limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n ...ProjectPageLatestItemsModels\n ...ModelPageProject\n ...HeaderNavShare_Project\n }\n }\n": types.ViewerLoadedResourcesDocument,
"\n query ViewerLoadedResources(\n $projectId: String!\n $modelIds: [String!]!\n $versionIds: [String!]\n ) {\n project(id: $projectId) {\n id\n role\n allowPublicComments\n models(filter: { ids: $modelIds }) {\n totalCount\n items {\n id\n name\n updatedAt\n loadedVersion: versions(\n filter: { priorityIds: $versionIds, priorityIdsOnly: true }\n ) {\n items {\n ...ViewerModelVersionCardItem\n automationsStatus {\n id\n automationRuns {\n ...AutomateViewerPanel_AutomateRun\n }\n }\n }\n }\n versions(limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n ...ProjectPageLatestItemsModels\n ...ModelPageProject\n ...HeaderNavShare_Project\n ...UseCheckViewerCommentingAccess_Project\n ...UseViewerUserActivityBroadcasting_Project\n }\n }\n": types.ViewerLoadedResourcesDocument,
"\n query ViewerModelVersions(\n $projectId: String!\n $modelId: String!\n $versionsCursor: String\n ) {\n project(id: $projectId) {\n id\n role\n model(id: $modelId) {\n id\n versions(cursor: $versionsCursor, limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n }\n": types.ViewerModelVersionsDocument,
"\n query ViewerDiffVersions(\n $projectId: String!\n $modelId: String!\n $versionAId: String!\n $versionBId: String!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n versionA: version(id: $versionAId) {\n ...ViewerModelVersionCardItem\n }\n versionB: version(id: $versionBId) {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n": types.ViewerDiffVersionsDocument,
"\n query ViewerLoadedThreads(\n $projectId: String!\n $filter: ProjectCommentsFilter!\n $cursor: String\n $limit: Int\n ) {\n project(id: $projectId) {\n id\n commentThreads(filter: $filter, cursor: $cursor, limit: $limit) {\n totalCount\n totalArchivedCount\n items {\n ...ViewerCommentThread\n ...LinkableComment\n }\n }\n }\n }\n": types.ViewerLoadedThreadsDocument,
@@ -816,10 +822,10 @@ const documents: Documents = {
"\n fragment AutomateFunctionPage_AutomateFunction on AutomateFunction {\n id\n name\n description\n logo\n supportedSourceApps\n tags\n ...AutomateFunctionPageHeader_Function\n ...AutomateFunctionPageInfo_AutomateFunction\n ...AutomateAutomationCreateDialog_AutomateFunction\n creator {\n id\n }\n }\n": types.AutomateFunctionPage_AutomateFunctionFragmentDoc,
"\n query AutomateFunctionPage($functionId: ID!) {\n automateFunction(id: $functionId) {\n ...AutomateFunctionPage_AutomateFunction\n }\n activeUser {\n workspaces {\n items {\n ...AutomateFunctionCreateDialog_Workspace\n ...AutomateFunctionEditDialog_Workspace\n }\n }\n }\n }\n": types.AutomateFunctionPageDocument,
"\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n": types.AutomateFunctionPageWorkspaceDocument,
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n }\n": types.ProjectPageProjectFragmentDoc,
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n ...ProjectPageSettingsTab_Project\n }\n": types.ProjectPageProjectFragmentDoc,
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": types.ProjectPageAutomationPage_AutomationFragmentDoc,
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": types.ProjectPageAutomationPage_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageSettingsTab_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageSettingsTab_ProjectFragmentDoc,
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsServerProjects_ProjectCollectionFragmentDoc,
"\n fragment SettingsServerProjects_User on User {\n permissions {\n canCreatePersonalProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.SettingsServerProjects_UserFragmentDoc,
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": types.SettingsServerRegionsDocument,
@@ -1322,6 +1328,10 @@ export function graphql(source: "\n fragment SettingsWorkspacesSecuritySsoWrapp
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n }\n"): (typeof documents)["\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2106,6 +2116,10 @@ export function graphql(source: "\n fragment EmailFields on UserEmail {\n id
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n"): (typeof documents)["\n query UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment UseViewerUserActivityBroadcasting_Project on Project {\n id\n permissions {\n canBroadcastActivity {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment UseViewerUserActivityBroadcasting_Project on Project {\n id\n permissions {\n canBroadcastActivity {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2113,7 +2127,11 @@ export function graphql(source: "\n fragment ViewerCommentBubblesData on Commen
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n }\n"): (typeof documents)["\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n }\n"];
export function graphql(source: "\n fragment UseCheckViewerCommentingAccess_Project on Project {\n id\n permissions {\n canCreateComment {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment UseCheckViewerCommentingAccess_Project on Project {\n id\n permissions {\n canCreateComment {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n"): (typeof documents)["\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2145,7 +2163,7 @@ export function graphql(source: "\n query ProjectViewerResources($projectId: St
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query ViewerLoadedResources(\n $projectId: String!\n $modelIds: [String!]!\n $versionIds: [String!]\n ) {\n project(id: $projectId) {\n id\n role\n allowPublicComments\n models(filter: { ids: $modelIds }) {\n totalCount\n items {\n id\n name\n updatedAt\n loadedVersion: versions(\n filter: { priorityIds: $versionIds, priorityIdsOnly: true }\n ) {\n items {\n ...ViewerModelVersionCardItem\n automationsStatus {\n id\n automationRuns {\n ...AutomateViewerPanel_AutomateRun\n }\n }\n }\n }\n versions(limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n ...ProjectPageLatestItemsModels\n ...ModelPageProject\n ...HeaderNavShare_Project\n }\n }\n"): (typeof documents)["\n query ViewerLoadedResources(\n $projectId: String!\n $modelIds: [String!]!\n $versionIds: [String!]\n ) {\n project(id: $projectId) {\n id\n role\n allowPublicComments\n models(filter: { ids: $modelIds }) {\n totalCount\n items {\n id\n name\n updatedAt\n loadedVersion: versions(\n filter: { priorityIds: $versionIds, priorityIdsOnly: true }\n ) {\n items {\n ...ViewerModelVersionCardItem\n automationsStatus {\n id\n automationRuns {\n ...AutomateViewerPanel_AutomateRun\n }\n }\n }\n }\n versions(limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n ...ProjectPageLatestItemsModels\n ...ModelPageProject\n ...HeaderNavShare_Project\n }\n }\n"];
export function graphql(source: "\n query ViewerLoadedResources(\n $projectId: String!\n $modelIds: [String!]!\n $versionIds: [String!]\n ) {\n project(id: $projectId) {\n id\n role\n allowPublicComments\n models(filter: { ids: $modelIds }) {\n totalCount\n items {\n id\n name\n updatedAt\n loadedVersion: versions(\n filter: { priorityIds: $versionIds, priorityIdsOnly: true }\n ) {\n items {\n ...ViewerModelVersionCardItem\n automationsStatus {\n id\n automationRuns {\n ...AutomateViewerPanel_AutomateRun\n }\n }\n }\n }\n versions(limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n ...ProjectPageLatestItemsModels\n ...ModelPageProject\n ...HeaderNavShare_Project\n ...UseCheckViewerCommentingAccess_Project\n ...UseViewerUserActivityBroadcasting_Project\n }\n }\n"): (typeof documents)["\n query ViewerLoadedResources(\n $projectId: String!\n $modelIds: [String!]!\n $versionIds: [String!]\n ) {\n project(id: $projectId) {\n id\n role\n allowPublicComments\n models(filter: { ids: $modelIds }) {\n totalCount\n items {\n id\n name\n updatedAt\n loadedVersion: versions(\n filter: { priorityIds: $versionIds, priorityIdsOnly: true }\n ) {\n items {\n ...ViewerModelVersionCardItem\n automationsStatus {\n id\n automationRuns {\n ...AutomateViewerPanel_AutomateRun\n }\n }\n }\n }\n versions(limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n ...ProjectPageLatestItemsModels\n ...ModelPageProject\n ...HeaderNavShare_Project\n ...UseCheckViewerCommentingAccess_Project\n ...UseViewerUserActivityBroadcasting_Project\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2421,7 +2439,7 @@ export function graphql(source: "\n query AutomateFunctionPageWorkspace($worksp
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n }\n"): (typeof documents)["\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n }\n"];
export function graphql(source: "\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n ...ProjectPageSettingsTab_Project\n }\n"): (typeof documents)["\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n ...ProjectPageSettingsTab_Project\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2433,7 +2451,7 @@ export function graphql(source: "\n fragment ProjectPageAutomationPage_Project
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ProjectPageSettingsTab_Project on Project {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsTab_Project on Project {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
export function graphql(source: "\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -306,6 +306,9 @@ function createCache(): InMemoryCache {
},
subscription: {
merge: mergeAsObjectsFunction
},
creationState: {
merge: mergeAsObjectsFunction
}
}
}
@@ -36,6 +36,7 @@ import {
useStateSerialization
} from '~~/lib/viewer/composables/serialization'
import type { Merge } from 'type-fest'
import { graphql } from '~/lib/common/generated/gql'
/**
* How often we send out an "activity" message even if user hasn't made any clicks (just to keep him active)
@@ -66,6 +67,17 @@ function useCollectMainMetadata() {
})
}
graphql(`
fragment UseViewerUserActivityBroadcasting_Project on Project {
id
permissions {
canBroadcastActivity {
...FullPermissionCheckResult
}
}
}
`)
export function useViewerUserActivityBroadcasting(
options?: Partial<{
state: InjectableViewerState
@@ -74,14 +86,18 @@ export function useViewerUserActivityBroadcasting(
const {
projectId,
resources: {
request: { resourceIdString }
request: { resourceIdString },
response: { project }
}
} = options?.state || useInjectedViewerState()
const { isLoggedIn } = useActiveUser()
const getMainMetadata = useCollectMainMetadata()
const apollo = useApolloClient().client
const { isEnabled: isEmbedEnabled } = useEmbed()
const canBroadcast = computed(
() => project.value?.permissions.canBroadcastActivity.authorized
)
const isSameMessage = (
previousSerializedMessage: Optional<string>,
newMessage: ViewerUserActivityMessageInput
@@ -118,7 +134,7 @@ export function useViewerUserActivityBroadcasting(
}
const invoke = async (message: ViewerUserActivityMessageInput) => {
if (!isLoggedIn.value || isEmbedEnabled.value) return false
if (!canBroadcast.value || isEmbedEnabled.value) return false
return await Promise.all([
invokeMutation(message),
invokeObservabilityEvent(message)
@@ -38,6 +38,7 @@ import {
StateApplyMode
} from '~~/lib/viewer/composables/serialization'
import type { CommentBubbleModel } from '~/lib/viewer/composables/commentBubbles'
import { graphql } from '~/lib/common/generated/gql'
export function useViewerCommentUpdateTracking(
params: {
@@ -229,21 +230,26 @@ export function useArchiveComment() {
}
}
graphql(`
fragment UseCheckViewerCommentingAccess_Project on Project {
id
permissions {
canCreateComment {
...FullPermissionCheckResult
}
}
}
`)
export function useCheckViewerCommentingAccess() {
const {
resources: {
response: { project }
}
} = useInjectedViewerState()
const { activeUser } = useActiveUser()
return computed(() => {
if (!activeUser.value) return false
const hasRole = !!project.value?.role
const allowPublicComments = !!project.value?.allowPublicComments
return hasRole || allowPublicComments
return project.value?.permissions.canCreateComment.authorized
})
}
@@ -20,11 +20,7 @@ import {
SpeckleLoader
} from '@speckle/viewer'
import { useAuthCookie } from '~~/lib/auth/composables/auth'
import type {
Project,
ProjectCommentThreadsArgs,
ViewerResourceItem
} from '~~/lib/common/generated/gql/graphql'
import type { ViewerResourceItem } from '~~/lib/common/generated/gql/graphql'
import { ProjectCommentsUpdatedMessageType } from '~~/lib/common/generated/gql/graphql'
import {
useInjectedViewer,
@@ -39,11 +35,7 @@ import {
useViewerEventListener
} from '~~/lib/viewer/composables/viewer'
import { useViewerCommentUpdateTracking } from '~~/lib/viewer/composables/commentManagement'
import {
getCacheId,
getObjectReference,
modifyObjectFields
} from '~~/lib/common/helpers/graphql'
import { getCacheId } from '~~/lib/common/helpers/graphql'
import {
useViewerOpenedThreadUpdateEmitter,
useViewerThreadTracking
@@ -264,21 +256,19 @@ function useViewerSubscriptionEventTracker() {
})
// Remove from project.commentThreads
modifyObjectFields<ProjectCommentThreadsArgs, Project['commentThreads']>(
modifyObjectField(
cache,
getCacheId('Project', projectId.value),
(fieldName, variables, data) => {
if (fieldName !== 'commentThreads') return
if (variables.filter?.includeArchived) return
'commentThreads',
({ variables, helpers: { createUpdatedValue, readField } }) => {
if (variables.filter?.includeArchived) return // we want it in that list
const newItems = (data.items || []).filter(
(i) => i.__ref !== getObjectReference('Comment', event.id).__ref
)
return {
...data,
...(data.items ? { items: newItems } : {}),
...(data.totalCount ? { totalCount: data.totalCount - 1 } : {})
}
return createUpdatedValue(({ update }) => {
update('totalCount', (totalCount) => totalCount - 1)
update('items', (items) =>
items.filter((i) => readField(i, 'id') !== event.id)
)
})
}
)
} else if (isNew && comment) {
@@ -298,21 +288,22 @@ function useViewerSubscriptionEventTracker() {
)
} else {
// Add comment thread
modifyObjectFields<ProjectCommentThreadsArgs, Project['commentThreads']>(
modifyObjectField(
cache,
getCacheId('Project', projectId.value),
(fieldName, _variables, data) => {
if (fieldName !== 'commentThreads') return
'commentThreads',
({ helpers: { ref, createUpdatedValue, readField }, value }) => {
// In case this is actually an unarchived comment, we only want to add it if it doesnt
// exist in the includesArchived list already
const includesItem = value.items?.find(
(i) => readField(i, 'id') === comment.id
)
if (includesItem) return
const newItems = [
getObjectReference('Comment', comment.id),
...(data.items || [])
]
return {
...data,
...(data.items ? { items: newItems } : {}),
...(data.totalCount ? { totalCount: data.totalCount + 1 } : {})
}
return createUpdatedValue(({ update }) => {
update('totalCount', (totalCount) => totalCount + 1)
update('items', (items) => [ref('Comment', comment.id), ...items])
})
}
)
}
@@ -5,6 +5,7 @@ export const viewerCommentThreadFragment = graphql(`
...ViewerCommentsListItem
...ViewerCommentBubblesData
...ViewerCommentsReplyItem
...ViewerCommentThreadData
}
`)
@@ -61,6 +61,8 @@ export const viewerLoadedResourcesQuery = graphql(`
...ProjectPageLatestItemsModels
...ModelPageProject
...HeaderNavShare_Project
...UseCheckViewerCommentingAccess_Project
...UseViewerUserActivityBroadcasting_Project
}
}
`)
@@ -105,6 +105,7 @@ graphql(`
...ProjectPageProjectHeader
...ProjectPageTeamDialog
...ProjectsMoveToWorkspaceDialog_Project
...ProjectPageSettingsTab_Project
}
`)
@@ -13,7 +13,7 @@
import { LayoutTabsVertical, type LayoutPageTabItem } from '@speckle/ui-components'
import { projectSettingsRoute, projectWebhooksRoute } from '~~/lib/common/helpers/route'
import { graphql } from '~~/lib/common/generated/gql'
import type { ProjectPageProjectFragment } from '~~/lib/common/generated/gql/graphql'
import type { ProjectPageSettingsTab_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
definePageMeta({
middleware: ['can-view-settings']
@@ -22,8 +22,9 @@ definePageMeta({
graphql(`
fragment ProjectPageSettingsTab_Project on Project {
id
name
permissions {
canUpdate {
canReadWebhooks {
...FullPermissionCheckResult
}
}
@@ -31,12 +32,12 @@ graphql(`
`)
const attrs = useAttrs() as {
project: ProjectPageProjectFragment
project: ProjectPageSettingsTab_ProjectFragment
}
const route = useRoute()
const router = useRouter()
const canUpdate = computed(() => attrs.project.permissions.canUpdate)
const canReadWebhooks = computed(() => attrs.project.permissions.canReadWebhooks)
const projectName = computed(() =>
attrs.project.name.length ? attrs.project.name : ''
)
@@ -53,8 +54,8 @@ const settingsTabItems = computed((): LayoutPageTabItem[] => [
{
title: 'Webhooks',
id: 'webhooks',
disabled: !canUpdate.value.authorized,
disabledMessage: canUpdate.value.message
disabled: !canReadWebhooks.value.authorized,
disabledMessage: canReadWebhooks.value.message
}
])
@@ -0,0 +1,12 @@
type CommentPermissionChecks {
canArchive: PermissionCheckResult!
}
extend type Comment {
permissions: CommentPermissionChecks!
}
extend type ProjectPermissionChecks {
canCreateComment: PermissionCheckResult!
canBroadcastActivity: PermissionCheckResult!
}
+1
View File
@@ -38,6 +38,7 @@ generates:
ServerStats: '@/modules/core/helpers/graphTypes#GraphQLEmptyReturn'
CommentReplyAuthorCollection: '@/modules/comments/helpers/graphTypes#CommentReplyAuthorCollectionGraphQLReturn'
Comment: '@/modules/comments/helpers/graphTypes#CommentGraphQLReturn'
CommentPermissionChecks: '@/modules/comments/helpers/graphTypes#CommentPermissionChecksGraphQLReturn'
PendingStreamCollaborator: '@/modules/serverinvites/helpers/graphTypes#PendingStreamCollaboratorGraphQLReturn'
StreamCollaborator: '@/modules/core/helpers/graphTypes#StreamCollaboratorGraphQLReturn'
ProjectCollaborator: '@/modules/core/helpers/graphTypes#ProjectCollaboratorGraphQLReturn'
@@ -0,0 +1,19 @@
import { defineModuleLoaders } from '@/modules/loaders'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
export default defineModuleLoaders(async () => {
return {
getComment: async ({ commentId, projectId }, { dataLoaders }) => {
const db = await getProjectDbClient({ projectId })
const comment = await dataLoaders
.forRegion({ db })
.comments.getComment.load(commentId)
if (!comment) return null
return {
...comment,
projectId
}
}
}
})
@@ -57,6 +57,8 @@ export type GetComment = (params: {
userId?: string
}) => Promise<Optional<ExtendedComment>>
export type GetComments = (params: { ids: string[] }) => Promise<CommentRecord[]>
export type CheckStreamResourceAccess = (
res: ResourceIdentifier,
streamId: string
@@ -0,0 +1,74 @@
import { defineRequestDataloaders } from '@/modules/shared/helpers/graphqlHelper'
import { keyBy } from 'lodash'
import { Nullable } from '@/modules/shared/helpers/typeHelper'
import { ResourceIdentifier } from '@/modules/core/graph/generated/graphql'
import {
getCommentParentsFactory,
getCommentReplyAuthorIdsFactory,
getCommentReplyCountsFactory,
getCommentsFactory,
getCommentsResourcesFactory,
getCommentsViewedAtFactory
} from '@/modules/comments/repositories/comments'
import { CommentRecord } from '@/modules/comments/helpers/types'
declare module '@/modules/core/loaders' {
interface ModularizedDataLoaders extends ReturnType<typeof dataLoadersDefinition> {}
}
const dataLoadersDefinition = defineRequestDataloaders(
({ ctx, createLoader, deps: { db } }) => {
const userId = ctx.userId
const getCommentsResources = getCommentsResourcesFactory({ db })
const getCommentsViewedAt = getCommentsViewedAtFactory({ db })
const getCommentReplyCounts = getCommentReplyCountsFactory({ db })
const getCommentReplyAuthorIds = getCommentReplyAuthorIdsFactory({ db })
const getCommentParents = getCommentParentsFactory({ db })
const getComments = getCommentsFactory({ db })
return {
comments: {
getComment: createLoader<string, Nullable<CommentRecord>>(
async (commentIds) => {
const results = keyBy(await getComments({ ids: commentIds.slice() }), 'id')
return commentIds.map((id) => results[id] || null)
}
),
getViewedAt: createLoader<string, Nullable<Date>>(async (commentIds) => {
if (!userId) return commentIds.slice().map(() => null)
const results = keyBy(
await getCommentsViewedAt(commentIds.slice(), userId),
'commentId'
)
return commentIds.map((id) => results[id]?.viewedAt || null)
}),
getResources: createLoader<string, ResourceIdentifier[]>(async (commentIds) => {
const results = await getCommentsResources(commentIds.slice())
return commentIds.map((id) => results[id]?.resources || [])
}),
getReplyCount: createLoader<string, number>(async (threadIds) => {
const results = keyBy(
await getCommentReplyCounts(threadIds.slice()),
'threadId'
)
return threadIds.map((id) => results[id]?.count || 0)
}),
getReplyAuthorIds: createLoader<string, string[]>(async (threadIds) => {
const results = await getCommentReplyAuthorIds(threadIds.slice())
return threadIds.map((id) => results[id] || [])
}),
getReplyParent: createLoader<string, Nullable<CommentRecord>>(
async (replyIds) => {
const results = keyBy(await getCommentParents(replyIds.slice()), 'replyId')
return replyIds.map((id) => results[id] || null)
}
)
}
}
}
)
export default dataLoadersDefinition
@@ -58,8 +58,6 @@ import {
getViewerResourceGroupsFactory
} from '@/modules/core/services/commit/viewerResources'
import {
authorizeProjectCommentsAccessFactory,
authorizeCommentAccessFactory,
createCommentThreadAndNotifyFactory,
createCommentReplyAndNotifyFactory,
editCommentAndNotifyFactory,
@@ -83,7 +81,6 @@ import {
getCommitsAndTheirBranchIdsFactory,
getSpecificBranchCommitsFactory
} from '@/modules/core/repositories/commits'
import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper'
import {
getBranchLatestCommitsFactory,
getStreamBranchesByNameFactory
@@ -93,20 +90,10 @@ import { getStreamFactory } from '@/modules/core/repositories/streams'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { Knex } from 'knex'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
// We can use the main DB for these
const getStream = getStreamFactory({ db })
const authorizeProjectCommentsAccess = authorizeProjectCommentsAccessFactory({
getStream,
adminOverrideEnabled
})
const buildAuthorizeCommentAccess = (deps: { db: Knex; mainDb: Knex }) =>
authorizeCommentAccessFactory({
getStream: getStreamFactory({ db: deps.mainDb }),
adminOverrideEnabled,
getComment: getCommentFactory(deps)
})
const buildGetViewerResourcesFromLegacyIdentifiers = (deps: { db: Knex }) => {
const getViewerResourcesFromLegacyIdentifiers =
@@ -133,20 +120,17 @@ const buildGetViewerResourceItemsUngrouped = (deps: { db: Knex }) =>
})
})
const getStreamCommentFactory =
const getAuthorizedStreamCommentFactory =
(deps: { db: Knex; mainDb: Knex }) =>
async (
{ streamId, commentId }: { streamId: string; commentId: string },
ctx: GraphQLContext
) => {
const authorizeProjectCommentsAccess = authorizeProjectCommentsAccessFactory({
getStream: getStreamFactory(deps),
adminOverrideEnabled
})
await authorizeProjectCommentsAccess({
projectId: streamId,
authCtx: ctx
const canReadProject = await ctx.authPolicies.project.canRead({
userId: ctx.userId,
projectId: streamId
})
throwIfAuthNotOk(canReadProject)
const getComment = getCommentFactory(deps)
const comment = await getComment({ id: commentId, userId: ctx.userId })
@@ -161,7 +145,10 @@ export = {
async comment(_parent, args, context) {
const projectId = args.streamId
const projectDb = await getProjectDbClient({ projectId })
const getStreamComment = getStreamCommentFactory({ db: projectDb, mainDb })
const getStreamComment = getAuthorizedStreamCommentFactory({
db: projectDb,
mainDb
})
return await getStreamComment(
{ streamId: args.streamId, commentId: args.id },
@@ -171,12 +158,13 @@ export = {
async comments(_parent, args, context) {
const projectId = args.streamId
const projectDb = await getProjectDbClient({ projectId })
await authorizeProjectCommentsAccess({
projectId: args.streamId,
authCtx: context
const canReadProject = await context.authPolicies.project.canRead({
userId: context.userId,
projectId
})
throwIfAuthNotOk(canReadProject)
const projectDb = await getProjectDbClient({ projectId })
const getComments = getCommentsLegacyFactory({ db: projectDb })
return {
...(await getComments({
@@ -356,12 +344,7 @@ export = {
}
},
Project: {
async commentThreads(parent, args, context) {
await authorizeProjectCommentsAccess({
projectId: parent.id,
authCtx: context
})
async commentThreads(parent, args) {
const projectDb = await getProjectDbClient({ projectId: parent.id })
const getPaginatedProjectComments = getPaginatedProjectCommentsFactory({
resolvePaginatedProjectCommentsLatestModelResources:
@@ -388,7 +371,10 @@ export = {
async comment(parent, args, context) {
const projectId = parent.id
const projectDb = await getProjectDbClient({ projectId })
const getStreamComment = getStreamCommentFactory({ db: projectDb, mainDb })
const getStreamComment = getAuthorizedStreamCommentFactory({
db: projectDb,
mainDb
})
return await getStreamComment(
{ streamId: parent.id, commentId: args.id },
context
@@ -396,13 +382,8 @@ export = {
}
},
Version: {
async commentThreads(parent, args, context) {
async commentThreads(parent, args) {
const projectId = parent.streamId
await authorizeProjectCommentsAccess({
projectId,
authCtx: context
})
const projectDb = await getProjectDbClient({ projectId })
const getPaginatedCommitComments = getPaginatedCommitCommentsFactory({
getPaginatedCommitCommentsPage: getPaginatedCommitCommentsPageFactory({
@@ -425,12 +406,8 @@ export = {
}
},
Model: {
async commentThreads(parent, args, context) {
async commentThreads(parent, args) {
const projectId = parent.streamId
await authorizeProjectCommentsAccess({
projectId,
authCtx: context
})
const projectDb = await getProjectDbClient({ projectId })
const getPaginatedBranchComments = getPaginatedBranchCommentsFactory({
@@ -498,16 +475,13 @@ export = {
},
CommentMutations: {
async markViewed(_parent, args, ctx) {
const projectDb = await getProjectDbClient({ projectId: args.input.projectId })
const authorizeCommentAccess = buildAuthorizeCommentAccess({
db: projectDb,
mainDb
})
await authorizeCommentAccess({
authCtx: ctx,
commentId: args.input.commentId
const canReadProject = await ctx.authPolicies.project.canRead({
userId: ctx.userId,
projectId: args.input.projectId
})
throwIfAuthNotOk(canReadProject)
const projectDb = await getProjectDbClient({ projectId: args.input.projectId })
const markCommentViewed = markCommentViewedFactory({ db: projectDb })
await markCommentViewed(args.input.commentId, ctx.userId!)
@@ -515,12 +489,11 @@ export = {
},
async create(_parent, args, ctx) {
const projectId = args.input.projectId
await authorizeProjectCommentsAccess({
projectId,
authCtx: ctx,
requireProjectRole: true
const canCreate = await ctx.authPolicies.project.comment.canCreate({
userId: ctx.userId,
projectId
})
throwIfAuthNotOk(canCreate)
const projectDb = await getProjectDbClient({ projectId })
@@ -547,17 +520,13 @@ export = {
return await createCommentThreadAndNotify(args.input, ctx.userId!)
},
async reply(_parent, args, ctx) {
const projectDb = await getProjectDbClient({ projectId: args.input.projectId })
const authorizeCommentAccess = buildAuthorizeCommentAccess({
db: projectDb,
mainDb
})
await authorizeCommentAccess({
commentId: args.input.threadId,
authCtx: ctx,
requireProjectRole: true
const canCreateComment = await ctx.authPolicies.project.comment.canCreate({
userId: ctx.userId,
projectId: args.input.projectId
})
throwIfAuthNotOk(canCreateComment)
const projectDb = await getProjectDbClient({ projectId: args.input.projectId })
const getComment = getCommentFactory({ db: projectDb })
const validateInputAttachments = validateInputAttachmentsFactory({
getBlobs: getBlobsFactory({ db: projectDb })
@@ -583,19 +552,16 @@ export = {
return await createCommentReplyAndNotify(args.input, ctx.userId!)
},
async edit(_parent, args, ctx) {
const canEditComment = await ctx.authPolicies.project.comment.canEdit({
projectId: args.input.projectId,
userId: ctx.userId,
commentId: args.input.commentId
})
throwIfAuthNotOk(canEditComment)
const projectDb = await getProjectDbClient({
projectId: args.input.projectId
})
const authorizeCommentAccess = buildAuthorizeCommentAccess({
db: projectDb,
mainDb
})
await authorizeCommentAccess({
authCtx: ctx,
commentId: args.input.commentId,
requireProjectRole: true
})
const getComment = getCommentFactory({ db: projectDb })
const validateInputAttachments = validateInputAttachmentsFactory({
getBlobs: getBlobsFactory({ db: projectDb })
@@ -612,19 +578,16 @@ export = {
return await editCommentAndNotify(args.input, ctx.userId!)
},
async archive(_parent, args, ctx) {
const canArchive = await ctx.authPolicies.project.comment.canArchive({
userId: ctx.userId,
projectId: args.input.projectId,
commentId: args.input.commentId
})
throwIfAuthNotOk(canArchive)
const projectDb = await getProjectDbClient({
projectId: args.input.projectId
})
const authorizeCommentAccess = buildAuthorizeCommentAccess({
db: projectDb,
mainDb
})
await authorizeCommentAccess({
authCtx: ctx,
commentId: args.input.commentId,
requireProjectRole: true
})
const getComment = getCommentFactory({ db: projectDb })
const getStream = getStreamFactory({ db: projectDb })
const updateComment = updateCommentFactory({ db: projectDb })
@@ -654,10 +617,12 @@ export = {
commentMutations: () => ({}),
async broadcastViewerUserActivity(_parent, args, context) {
const projectId = args.projectId
await authorizeProjectCommentsAccess({
projectId,
authCtx: context
})
const canBroadcastActivity =
await context.authPolicies.project.canBroadcastActivity({
projectId,
userId: context.userId
})
throwIfAuthNotOk(canBroadcastActivity)
const projectDb = await getProjectDbClient({ projectId })
const getViewerResourceItemsUngrouped = buildGetViewerResourceItemsUngrouped({
@@ -674,10 +639,13 @@ export = {
},
async userViewerActivityBroadcast(_parent, args, context) {
await authorizeProjectCommentsAccess({
projectId: args.streamId,
authCtx: context
})
const projectId = args.streamId
const canBroadcastActivity =
await context.authPolicies.project.canBroadcastActivity({
projectId,
userId: context.userId
})
throwIfAuthNotOk(canBroadcastActivity)
await pubsub.publish(CommentSubscriptions.ViewerActivity, {
userViewerActivity: args.data,
@@ -707,16 +675,11 @@ export = {
},
async commentCreate(_parent, args, context) {
if (!context.userId)
throw new ForbiddenError('Only registered users can comment.')
const stream = await getStream({
streamId: args.input.streamId,
userId: context.userId
const canCreate = await context.authPolicies.project.comment.canCreate({
userId: context.userId,
projectId: args.input.streamId
})
if (!stream?.allowPublicComments && !stream?.role)
throw new ForbiddenError('You are not authorized.')
throwIfAuthNotOk(canCreate)
const projectDb = await getProjectDbClient({ projectId: args.input.streamId })
const getViewerResourcesFromLegacyIdentifiers =
@@ -737,7 +700,7 @@ export = {
getViewerResourcesFromLegacyIdentifiers
})
const comment = await createComment({
userId: context.userId,
userId: context.userId!,
input: args.input
})
@@ -745,13 +708,12 @@ export = {
},
async commentEdit(_parent, args, context) {
// NOTE: This is NOT in use anywhere
const stream = await authorizeProjectCommentsAccess({
const canEdit = await context.authPolicies.project.comment.canEdit({
userId: context.userId,
projectId: args.input.streamId,
authCtx: context,
requireProjectRole: true
commentId: args.input.id
})
const matchUser = !stream.role
throwIfAuthNotOk(canEdit)
const projectDb = await getProjectDbClient({ projectId: args.input.streamId })
const editComment = editCommentFactory({
@@ -763,16 +725,17 @@ export = {
emitEvent: getEventBus().emit
})
await editComment({ userId: context.userId!, input: args.input, matchUser })
await editComment({ userId: context.userId!, input: args.input })
return true
},
// used for flagging a comment as viewed
async commentView(_parent, args, context) {
await authorizeProjectCommentsAccess({
projectId: args.streamId,
authCtx: context
const canReadProject = await context.authPolicies.project.canRead({
userId: context.userId,
projectId: args.streamId
})
throwIfAuthNotOk(canReadProject)
const projectDb = await getProjectDbClient({ projectId: args.streamId })
const markCommentViewed = markCommentViewedFactory({ db: projectDb })
@@ -782,11 +745,12 @@ export = {
},
async commentArchive(_parent, args, context) {
await authorizeProjectCommentsAccess({
const canArchive = await context.authPolicies.project.comment.canArchive({
userId: context.userId,
projectId: args.streamId,
authCtx: context,
requireProjectRole: true
commentId: args.commentId
})
throwIfAuthNotOk(canArchive)
const projectDb = await getProjectDbClient({ projectId: args.streamId })
const archiveComment = archiveCommentFactory({
@@ -849,15 +813,13 @@ export = {
subscribe: filteredSubscribe(
CommentSubscriptions.ViewerActivity,
async (payload, variables, context) => {
const stream = await getStream({
streamId: payload.streamId,
userId: context.userId
const canReadProject = await context.authPolicies.project.canRead({
userId: context.userId,
projectId: payload.streamId
})
throwIfAuthNotOk(canReadProject)
if (!stream?.allowPublicComments && !stream?.role)
throw new ForbiddenError('You are not authorized.')
// dont report users activity to himself
// dont report user's activity to themselves
if (context.userId && context.userId === payload.authorId) {
return false
}
@@ -873,13 +835,11 @@ export = {
subscribe: filteredSubscribe(
CommentSubscriptions.CommentActivity,
async (payload, variables, context) => {
const stream = await getStream({
streamId: payload.streamId,
userId: context.userId
const canReadProject = await context.authPolicies.project.canRead({
userId: context.userId,
projectId: payload.streamId
})
if (!stream?.allowPublicComments && !stream?.role)
throw new ForbiddenError('You are not authorized.')
throwIfAuthNotOk(canReadProject)
// if we're listening for a stream's root comments events
if (!variables.resourceIds) {
@@ -929,13 +889,11 @@ export = {
subscribe: filteredSubscribe(
CommentSubscriptions.CommentThreadActivity,
async (payload, variables, context) => {
const stream = await getStream({
streamId: payload.streamId,
userId: context.userId
const canReadProject = await context.authPolicies.project.canRead({
userId: context.userId,
projectId: payload.streamId
})
if (!stream?.allowPublicComments && !stream?.role)
throw new ForbiddenError('You are not authorized.')
throwIfAuthNotOk(canReadProject)
return (
payload.streamId === variables.streamId &&
@@ -955,21 +913,17 @@ export = {
if (!target.resourceIdString.trim().length) return false
if (payload.projectId !== target.projectId) return false
const canReadProject = await context.authPolicies.project.canRead({
userId: context.userId,
projectId: payload.projectId
})
throwIfAuthNotOk(canReadProject)
const projectDb = await getProjectDbClient({ projectId: payload.projectId })
const getViewerResourceItemsUngrouped = buildGetViewerResourceItemsUngrouped({
db: projectDb
})
const [stream, requestedResourceItems] = await Promise.all([
getStream({
streamId: payload.projectId,
userId: context.userId
}),
getViewerResourceItemsUngrouped(target)
])
if (!stream?.isPublic && !stream?.role)
throw new ForbiddenError('You are not authorized.')
const requestedResourceItems = await getViewerResourceItemsUngrouped(target)
// dont report users activity to himself
if (
@@ -995,21 +949,18 @@ export = {
const target = variables.target
if (payload.projectId !== target.projectId) return false
const canReadProject = await context.authPolicies.project.canRead({
userId: context.userId,
projectId: payload.projectId
})
throwIfAuthNotOk(canReadProject)
const projectDb = await getProjectDbClient({ projectId: payload.projectId })
const getViewerResourceItemsUngrouped = buildGetViewerResourceItemsUngrouped({
db: projectDb
})
const [stream, requestedResourceItems] = await Promise.all([
getStream({
streamId: payload.projectId,
userId: context.userId
}),
getViewerResourceItemsUngrouped(target)
])
if (!(stream?.isDiscoverable || stream?.isPublic) && !stream?.role)
throw new ForbiddenError('You are not authorized.')
const requestedResourceItems = await getViewerResourceItemsUngrouped(target)
if (!target.resourceIdString) {
return true
@@ -0,0 +1,36 @@
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { Authz } from '@speckle/shared'
export default {
Comment: {
permissions: async (parent) => ({
commentId: parent.id,
projectId: parent.streamId
})
},
CommentPermissionChecks: {
canArchive: async (parent, _args, ctx) => {
const canArchive = await ctx.authPolicies.project.comment.canArchive({
...parent,
userId: ctx.userId
})
return Authz.toGraphqlResult(canArchive)
}
},
ProjectPermissionChecks: {
canCreateComment: async (parent, _args, ctx) => {
const canCreateComment = await ctx.authPolicies.project.comment.canCreate({
...parent,
userId: ctx.userId
})
return Authz.toGraphqlResult(canCreateComment)
},
canBroadcastActivity: async (parent, _args, ctx) => {
const canBroadcastActivity = await ctx.authPolicies.project.canBroadcastActivity({
...parent,
userId: ctx.userId
})
return Authz.toGraphqlResult(canBroadcastActivity)
}
}
} as Resolvers
@@ -14,3 +14,8 @@ export type CommentReplyAuthorCollectionGraphQLReturn = {
}
export type CommentGraphQLReturn = CommentRecord
export type CommentPermissionChecksGraphQLReturn = {
commentId: string
projectId: string
}
@@ -37,6 +37,7 @@ import {
GetCommentParents,
GetCommentReplyAuthorIds,
GetCommentReplyCounts,
GetComments,
GetCommentsResources,
GetCommitCommentCounts,
GetPaginatedBranchCommentsPage,
@@ -106,6 +107,18 @@ export const getCommentFactory =
return await query
}
export const getCommentsFactory =
(deps: { db: Knex }): GetComments =>
async (params) => {
const { ids } = params
if (!ids.length) return []
const query = tables.comments(deps.db).select<CommentRecord[]>('*')
query.whereIn(Comments.col.id, ids)
return await query
}
/**
* Get resources array for the specified comments. Results object is keyed by comment ID.
*/
@@ -247,7 +247,7 @@ export const editCommentFactory =
}: {
userId: string
input: CommentEditInput
matchUser: boolean
matchUser?: boolean
}) => {
const editedComment = await deps.getComment({ id: input.id })
if (!editedComment) throw new CommentNotFoundError("The comment doesn't exist")
@@ -90,33 +90,6 @@ export const authorizeProjectCommentsAccessFactory =
return project
}
export const authorizeCommentAccessFactory =
(
deps: {
getComment: GetComment
} & AuthorizeProjectCommentsAccessDeps
) =>
async (params: {
authCtx: AuthContext
commentId: string
requireProjectRole?: boolean
}) => {
const { authCtx, commentId, requireProjectRole } = params
const comment = await deps.getComment({
id: commentId,
userId: authCtx.userId
})
if (!comment) {
throw new StreamInvalidAccessError('Attempting to access a nonexistant comment')
}
return authorizeProjectCommentsAccessFactory(deps)({
projectId: comment.streamId,
authCtx,
requireProjectRole
})
}
export const createCommentThreadAndNotifyFactory =
(deps: {
getViewerResourceItemsUngrouped: GetViewerResourceItemsUngrouped
@@ -311,10 +284,7 @@ export const archiveCommentAndNotifyFactory =
}
const stream = await deps.getStream({ streamId: comment.streamId, userId })
if (
!stream ||
(comment.authorId !== userId && stream.role !== Roles.Stream.Owner)
) {
if (!stream) {
throw new CommentUpdateError(
'You do not have permissions to archive this comment'
)
@@ -855,7 +855,7 @@ describe('Graphql @comments', () => {
[archiveMyComment, true],
[archiveOthersComment, true],
[editMyComment, true],
[editOthersComment, true],
[editOthersComment, false],
[replyToAComment, true],
[queryComment, true],
[queryComments, true],
@@ -875,7 +875,7 @@ describe('Graphql @comments', () => {
[archiveMyComment, true],
[archiveOthersComment, false],
[editMyComment, true],
[editOthersComment, true],
[editOthersComment, false],
[replyToAComment, true],
[queryComment, true],
[queryComments, true],
@@ -895,7 +895,7 @@ describe('Graphql @comments', () => {
[archiveMyComment, true],
[archiveOthersComment, false],
[editMyComment, true],
[editOthersComment, true],
[editOthersComment, false],
[replyToAComment, true],
[queryComment, true],
[queryComments, true],
@@ -978,8 +978,8 @@ describe('Graphql @comments', () => {
[archiveOthersComment, false],
[editOthersComment, false],
[replyToAComment, false],
[queryComment, false],
[queryComments, false],
[queryComment, true],
[queryComments, true],
[queryStreamCommentCount, false],
[queryObjectCommentCount, false],
[queryCommitCommentCount, false],
@@ -996,8 +996,8 @@ describe('Graphql @comments', () => {
[archiveOthersComment, false],
[editOthersComment, false],
[replyToAComment, false],
[queryComment, false],
[queryComments, false],
[queryComment, true],
[queryComments, true],
[queryStreamCommentCount, false],
[queryObjectCommentCount, false],
[queryCommitCommentCount, false],
@@ -1164,13 +1164,13 @@ describe('Graphql @comments', () => {
}
})
describe(`testing ${streamContext.cases.length} cases of acting on ${
describe(`testing ${streamContext.cases.length} cases of acting on "${
stream.name
} stream where I'm a ${
user && stream.role ? stream.role : 'trouble:maker'
}" stream where I ${
user && stream.role ? 'have the role ' + stream.role : 'have no role'
}`, () => {
streamContext.cases.forEach(([testCase, shouldSucceed]) => {
it(`${shouldSucceed ? 'can' : 'am not allowed to'} ${
it(`${shouldSucceed ? 'should' : 'should not be allowed to'} ${
testCase.name
}`, async () => {
await testCase({ apollo, streamId: stream.id, resources, shouldSucceed })
@@ -33,14 +33,9 @@ import {
getUserAuthoredCommitCountsFactory,
getUserStreamCommitCountsFactory
} from '@/modules/core/repositories/commits'
import { ResourceIdentifier, Scope } from '@/modules/core/graph/generated/graphql'
import { Scope } from '@/modules/core/graph/generated/graphql'
import {
getBranchCommentCountsFactory,
getCommentParentsFactory,
getCommentReplyAuthorIdsFactory,
getCommentReplyCountsFactory,
getCommentsResourcesFactory,
getCommentsViewedAtFactory,
getCommitCommentCountsFactory,
getStreamCommentCountsFactory
} from '@/modules/comments/repositories/comments'
@@ -51,7 +46,6 @@ import {
getStreamBranchCountsFactory,
getStreamBranchesByNameFactory
} from '@/modules/core/repositories/branches'
import { CommentRecord } from '@/modules/comments/helpers/types'
import { metaHelpers } from '@/modules/core/helpers/meta'
import { Users } from '@/modules/core/dbSchema'
import { getStreamPendingModelsFactory } from '@/modules/fileuploads/repositories/fileUploads'
@@ -119,13 +113,8 @@ const dataLoadersDefinition = defineRequestDataloaders(
const getRevisionsFunctions = getRevisionsFunctionsFactory({ db })
const getStreamCommentCounts = getStreamCommentCountsFactory({ db })
const getAutomationRunsTriggers = getAutomationRunsTriggersFactory({ db })
const getCommentsResources = getCommentsResourcesFactory({ db })
const getCommentsViewedAt = getCommentsViewedAtFactory({ db })
const getCommitCommentCounts = getCommitCommentCountsFactory({ db })
const getBranchCommentCounts = getBranchCommentCountsFactory({ db })
const getCommentReplyCounts = getCommentReplyCountsFactory({ db })
const getCommentReplyAuthorIds = getCommentReplyAuthorIdsFactory({ db })
const getCommentParents = getCommentParentsFactory({ db })
const getBranchesByIds = getBranchesByIdsFactory({ db })
const getStreamBranchesByName = getStreamBranchesByNameFactory({ db })
const getBranchLatestCommits = getBranchLatestCommitsFactory({ db })
@@ -469,38 +458,6 @@ const dataLoadersDefinition = defineRequestDataloaders(
return commitIds.map((i) => results[i] || null)
})
},
comments: {
getViewedAt: createLoader<string, Nullable<Date>>(async (commentIds) => {
if (!userId) return commentIds.slice().map(() => null)
const results = keyBy(
await getCommentsViewedAt(commentIds.slice(), userId),
'commentId'
)
return commentIds.map((id) => results[id]?.viewedAt || null)
}),
getResources: createLoader<string, ResourceIdentifier[]>(async (commentIds) => {
const results = await getCommentsResources(commentIds.slice())
return commentIds.map((id) => results[id]?.resources || [])
}),
getReplyCount: createLoader<string, number>(async (threadIds) => {
const results = keyBy(
await getCommentReplyCounts(threadIds.slice()),
'threadId'
)
return threadIds.map((id) => results[id]?.count || 0)
}),
getReplyAuthorIds: createLoader<string, string[]>(async (threadIds) => {
const results = await getCommentReplyAuthorIds(threadIds.slice())
return threadIds.map((id) => results[id] || [])
}),
getReplyParent: createLoader<string, Nullable<CommentRecord>>(
async (replyIds) => {
const results = keyBy(await getCommentParents(replyIds.slice()), 'replyId')
return replyIds.map((id) => results[id] || null)
}
)
},
users: {
/**
* Get user from DB
@@ -1,7 +1,7 @@
import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql';
import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, ObjectGraphQLReturn, VersionGraphQLReturn, ServerInviteGraphQLReturnType, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, MutationsObjectGraphQLReturn, LimitedUserGraphQLReturn, UserGraphQLReturn, GraphQLEmptyReturn, StreamCollaboratorGraphQLReturn, ProjectCollaboratorGraphQLReturn, ServerInfoGraphQLReturn, BranchGraphQLReturn, ProjectPermissionChecksGraphQLReturn, RootPermissionChecksGraphQLReturn } from '@/modules/core/helpers/graphTypes';
import { StreamAccessRequestGraphQLReturn, ProjectAccessRequestGraphQLReturn } from '@/modules/accessrequests/helpers/graphTypes';
import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn } from '@/modules/comments/helpers/graphTypes';
import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn, CommentPermissionChecksGraphQLReturn } from '@/modules/comments/helpers/graphTypes';
import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/helpers/graphTypes';
import { FileUploadGraphQLReturn } from '@/modules/fileuploads/helpers/types';
import { AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes';
@@ -631,6 +631,7 @@ export type Comment = {
id: Scalars['String']['output'];
/** Parent thread, if there's any */
parent?: Maybe<Comment>;
permissions: CommentPermissionChecks;
/** Plain-text version of the comment text, ideal for previews */
rawText: Scalars['String']['output'];
/** @deprecated Not actually implemented */
@@ -764,6 +765,11 @@ export type CommentMutationsReplyArgs = {
input: CreateCommentReplyInput;
};
export type CommentPermissionChecks = {
__typename?: 'CommentPermissionChecks';
canArchive: PermissionCheckResult;
};
export type CommentReplyAuthorCollection = {
__typename?: 'CommentReplyAuthorCollection';
items: Array<LimitedUser>;
@@ -2560,6 +2566,8 @@ export const ProjectPendingVersionsUpdatedMessageType = {
export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVersionsUpdatedMessageType[keyof typeof ProjectPendingVersionsUpdatedMessageType];
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canBroadcastActivity: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canLeave: PermissionCheckResult;
canMoveToWorkspace: PermissionCheckResult;
@@ -5189,6 +5197,7 @@ export type ResolversTypes = {
CommentDataFiltersInput: CommentDataFiltersInput;
CommentEditInput: CommentEditInput;
CommentMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
CommentPermissionChecks: ResolverTypeWrapper<CommentPermissionChecksGraphQLReturn>;
CommentReplyAuthorCollection: ResolverTypeWrapper<CommentReplyAuthorCollectionGraphQLReturn>;
CommentThreadActivityMessage: ResolverTypeWrapper<Omit<CommentThreadActivityMessage, 'reply'> & { reply?: Maybe<ResolversTypes['Comment']> }>;
Commit: ResolverTypeWrapper<CommitGraphQLReturn>;
@@ -5516,6 +5525,7 @@ export type ResolversParentTypes = {
CommentDataFiltersInput: CommentDataFiltersInput;
CommentEditInput: CommentEditInput;
CommentMutations: MutationsObjectGraphQLReturn;
CommentPermissionChecks: CommentPermissionChecksGraphQLReturn;
CommentReplyAuthorCollection: CommentReplyAuthorCollectionGraphQLReturn;
CommentThreadActivityMessage: Omit<CommentThreadActivityMessage, 'reply'> & { reply?: Maybe<ResolversParentTypes['Comment']> };
Commit: CommitGraphQLReturn;
@@ -6094,6 +6104,7 @@ export type CommentResolvers<ContextType = GraphQLContext, ParentType extends Re
hasParent?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
parent?: Resolver<Maybe<ResolversTypes['Comment']>, ParentType, ContextType>;
permissions?: Resolver<ResolversTypes['CommentPermissionChecks'], ParentType, ContextType>;
rawText?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
reactions?: Resolver<Maybe<Array<Maybe<ResolversTypes['String']>>>, ParentType, ContextType>;
replies?: Resolver<ResolversTypes['CommentCollection'], ParentType, ContextType, RequireFields<CommentRepliesArgs, 'limit'>>;
@@ -6140,6 +6151,11 @@ export type CommentMutationsResolvers<ContextType = GraphQLContext, ParentType e
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CommentPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['CommentPermissionChecks'] = ResolversParentTypes['CommentPermissionChecks']> = {
canArchive?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CommentReplyAuthorCollectionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['CommentReplyAuthorCollection'] = ResolversParentTypes['CommentReplyAuthorCollection']> = {
items?: Resolver<Array<ResolversTypes['LimitedUser']>, ParentType, ContextType>;
totalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
@@ -6659,6 +6675,8 @@ export type ProjectPendingVersionsUpdatedMessageResolvers<ContextType = GraphQLC
};
export type ProjectPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ProjectPermissionChecks'] = ResolversParentTypes['ProjectPermissionChecks']> = {
canBroadcastActivity?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateComment?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateModel?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canLeave?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canMoveToWorkspace?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType, Partial<ProjectPermissionChecksCanMoveToWorkspaceArgs>>;
@@ -7529,6 +7547,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
CommentCollection?: CommentCollectionResolvers<ContextType>;
CommentDataFilters?: CommentDataFiltersResolvers<ContextType>;
CommentMutations?: CommentMutationsResolvers<ContextType>;
CommentPermissionChecks?: CommentPermissionChecksResolvers<ContextType>;
CommentReplyAuthorCollection?: CommentReplyAuthorCollectionResolvers<ContextType>;
CommentThreadActivityMessage?: CommentThreadActivityMessageResolvers<ContextType>;
Commit?: CommitResolvers<ContextType>;
@@ -81,7 +81,7 @@ export = {
resourceAccessRules: context.resourceAccessRules
})
const canCreate = await context.authPolicies.project.canCreateModel({
const canCreate = await context.authPolicies.project.model.canCreate({
userId: context.userId,
projectId: args.branch.streamId
})
@@ -297,7 +297,7 @@ export = {
},
ModelMutations: {
async create(_parent, args, ctx) {
const canCreate = await ctx.authPolicies.project.canCreateModel({
const canCreate = await ctx.authPolicies.project.model.canCreate({
userId: ctx.userId,
projectId: args.input.projectId
})
@@ -10,7 +10,7 @@ export default {
},
ProjectPermissionChecks: {
canCreateModel: async (parent, _args, ctx) => {
const canCreateModel = await ctx.authPolicies.project.canCreateModel({
const canCreateModel = await ctx.authPolicies.project.model.canCreate({
userId: ctx.userId,
projectId: parent.projectId
})
@@ -611,6 +611,7 @@ export type Comment = {
id: Scalars['String']['output'];
/** Parent thread, if there's any */
parent?: Maybe<Comment>;
permissions: CommentPermissionChecks;
/** Plain-text version of the comment text, ideal for previews */
rawText: Scalars['String']['output'];
/** @deprecated Not actually implemented */
@@ -744,6 +745,11 @@ export type CommentMutationsReplyArgs = {
input: CreateCommentReplyInput;
};
export type CommentPermissionChecks = {
__typename?: 'CommentPermissionChecks';
canArchive: PermissionCheckResult;
};
export type CommentReplyAuthorCollection = {
__typename?: 'CommentReplyAuthorCollection';
items: Array<LimitedUser>;
@@ -2540,6 +2546,8 @@ export const ProjectPendingVersionsUpdatedMessageType = {
export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVersionsUpdatedMessageType[keyof typeof ProjectPendingVersionsUpdatedMessageType];
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canBroadcastActivity: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canLeave: PermissionCheckResult;
canMoveToWorkspace: PermissionCheckResult;
@@ -1,6 +1,11 @@
import { StreamNotFoundError } from '@/modules/core/errors/stream'
import { WorkspacesModuleDisabledError } from '@/modules/core/errors/workspaces'
import { BadRequestError, BaseError, ForbiddenError } from '@/modules/shared/errors'
import {
BadRequestError,
BaseError,
ForbiddenError,
NotFoundError
} from '@/modules/shared/errors'
import { SsoSessionMissingOrExpiredError } from '@/modules/workspacesCore/errors'
import { Authz, ensureError, throwUncoveredError } from '@speckle/shared'
import { VError } from 'verror'
@@ -34,6 +39,7 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
case Authz.WorkspaceLimitsReachedError.code:
case Authz.WorkspaceNoEditorSeatError.code:
case Authz.WorkspaceProjectMoveInvalidError.code:
case Authz.CommentNoAccessError.code:
return new ForbiddenError(e.message)
case Authz.WorkspaceSsoSessionNoAccessError.code:
throw new SsoSessionMissingOrExpiredError(e.message, {
@@ -48,6 +54,8 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
return new WorkspacesModuleDisabledError()
case Authz.ProjectLastOwnerError.code:
return new BadRequestError(e.message)
case Authz.CommentNotFoundError.code:
return new NotFoundError(e.message)
default:
throwUncoveredError(e)
}
@@ -612,6 +612,7 @@ export type Comment = {
id: Scalars['String']['output'];
/** Parent thread, if there's any */
parent?: Maybe<Comment>;
permissions: CommentPermissionChecks;
/** Plain-text version of the comment text, ideal for previews */
rawText: Scalars['String']['output'];
/** @deprecated Not actually implemented */
@@ -745,6 +746,11 @@ export type CommentMutationsReplyArgs = {
input: CreateCommentReplyInput;
};
export type CommentPermissionChecks = {
__typename?: 'CommentPermissionChecks';
canArchive: PermissionCheckResult;
};
export type CommentReplyAuthorCollection = {
__typename?: 'CommentReplyAuthorCollection';
items: Array<LimitedUser>;
@@ -2541,6 +2547,8 @@ export const ProjectPendingVersionsUpdatedMessageType = {
export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVersionsUpdatedMessageType[keyof typeof ProjectPendingVersionsUpdatedMessageType];
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canBroadcastActivity: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canLeave: PermissionCheckResult;
canMoveToWorkspace: PermissionCheckResult;
@@ -127,6 +127,16 @@ export const ServerNoSessionError = defineAuthError({
message: 'You are not logged in to this server'
})
export const CommentNotFoundError = defineAuthError({
code: 'CommentNotFound',
message: 'Comment not found'
})
export const CommentNoAccessError = defineAuthError({
code: 'CommentNoAccess',
message: 'You do not have access to this comment'
})
// Resolve all exported error types
export type AllAuthErrors = ValueOf<{
[key in keyof typeof import('./authErrors.js')]: typeof import('./authErrors.js')[key] extends new (
@@ -0,0 +1,6 @@
import { Comment } from './types.js'
export type GetComment = (args: {
commentId: string
projectId: string
}) => Promise<Comment | null>
@@ -0,0 +1,5 @@
export type Comment = {
id: string
authorId: string
projectId: string
}
@@ -3,4 +3,5 @@ export type MaybeProjectContext = { projectId?: string }
export type UserContext = { userId: string }
export type MaybeUserContext = { userId?: string }
export type WorkspaceContext = { workspaceId: string }
export type CommentContext = { commentId: string }
export type MaybeWorkspaceContext = { workspaceId?: string }
+4 -1
View File
@@ -19,6 +19,7 @@ import type {
GetWorkspaceSsoProvider,
GetWorkspaceSsoSession
} from './workspaces/operations.js'
import { GetComment } from './comments/operations.js'
// utility type that ensures all properties functions that return promises
type PromiseAll<T> = {
@@ -63,7 +64,8 @@ export const AuthCheckContextLoaderKeys = <const>{
getWorkspaceLimits: 'getWorkspaceLimits',
getWorkspaceSsoProvider: 'getWorkspaceSsoProvider',
getWorkspaceSsoSession: 'getWorkspaceSsoSession',
getAdminOverrideEnabled: 'getAdminOverrideEnabled'
getAdminOverrideEnabled: 'getAdminOverrideEnabled',
getComment: 'getComment'
}
export const Loaders = AuthCheckContextLoaderKeys // shorter alias
/* v8 ignore end */
@@ -87,6 +89,7 @@ export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{
getWorkspaceModelCount: GetWorkspaceModelCount
getWorkspaceSsoProvider: GetWorkspaceSsoProvider
getWorkspaceSsoSession: GetWorkspaceSsoSession
getComment: GetComment
}>
export type AuthCheckContextLoaders<
@@ -6,6 +6,7 @@ export type Project = {
isDiscoverable: boolean
isPublic: boolean
workspaceId: string | null
allowPublicComments: boolean
}
export type ProjectVisibility = 'public' | 'linkShareable' | 'private'
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
checkIfPubliclyReadableProjectFragment,
ensureImplicitProjectMemberWithReadAccessFragment,
ensureImplicitProjectMemberWithWriteAccessFragment,
ensureMinimumProjectRoleFragment,
ensureProjectWorkspaceAccessFragment
} from './projects.js'
@@ -16,11 +17,12 @@ import {
WorkspaceSsoSessionNoAccessError
} from '../domain/authErrors.js'
import { OverridesOf } from '../../tests/helpers/types.js'
import { getProjectFake } from '../../tests/fakes.js'
describe('ensureMinimumProjectRoleFragment', () => {
const buildSUT = (overrides?: OverridesOf<typeof ensureMinimumProjectRoleFragment>) =>
ensureMinimumProjectRoleFragment({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -40,7 +42,7 @@ describe('ensureMinimumProjectRoleFragment', () => {
overrides?: OverridesOf<typeof ensureMinimumProjectRoleFragment>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: 'workspaceId',
isDiscoverable: false,
@@ -160,7 +162,7 @@ describe('checkIfPubliclyReadableProjectFragment', () => {
overrides?: OverridesOf<typeof checkIfPubliclyReadableProjectFragment>
) =>
checkIfPubliclyReadableProjectFragment({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -192,7 +194,7 @@ describe('checkIfPubliclyReadableProjectFragment', () => {
it('returns true if project is public', async () => {
const sut = buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -209,7 +211,7 @@ describe('checkIfPubliclyReadableProjectFragment', () => {
it('returns false if project is not public', async () => {
const sut = buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -228,7 +230,7 @@ describe('ensureProjectWorkspaceAccessFragment', () => {
overrides?: OverridesOf<typeof ensureProjectWorkspaceAccessFragment>
) =>
ensureProjectWorkspaceAccessFragment({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -246,7 +248,7 @@ describe('ensureProjectWorkspaceAccessFragment', () => {
overrides?: OverridesOf<typeof ensureProjectWorkspaceAccessFragment>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: 'workspaceId',
isDiscoverable: false,
@@ -384,7 +386,7 @@ describe('ensureImplicitProjectMemberWithReadAccessFragment', async () => {
overrides?: OverridesOf<typeof ensureImplicitProjectMemberWithReadAccessFragment>
) =>
ensureImplicitProjectMemberWithReadAccessFragment({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -405,7 +407,7 @@ describe('ensureImplicitProjectMemberWithReadAccessFragment', async () => {
overrides?: OverridesOf<typeof ensureImplicitProjectMemberWithReadAccessFragment>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: 'workspaceId',
isDiscoverable: false,
@@ -599,3 +601,275 @@ describe('ensureImplicitProjectMemberWithReadAccessFragment', async () => {
})
})
})
describe('ensureImplicitProjectMemberWithWriteAccessFragment', () => {
const buildSUT = (
overrides?: OverridesOf<typeof ensureImplicitProjectMemberWithWriteAccessFragment>
) =>
ensureImplicitProjectMemberWithWriteAccessFragment({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getServerRole: async () => Roles.Server.User,
getProjectRole: async () => Roles.Stream.Contributor,
getEnv: async () => parseFeatureFlags({}),
getWorkspace: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getWorkspaceRole: async () => null,
...overrides
})
const buildWorkspaceSUT = (
overrides?: OverridesOf<typeof ensureImplicitProjectMemberWithWriteAccessFragment>
) =>
buildSUT({
getProject: getProjectFake({
id: 'projectId',
workspaceId: 'workspaceId',
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspaceId',
slug: 'workspaceSlug'
}),
getWorkspaceSsoProvider: async () => ({
providerId: 'ssoProviderId'
}),
getWorkspaceSsoSession: async () => ({
providerId: 'ssoSessionId',
userId: 'userId',
validUntil: new Date(Date.now() + 1000 * 60 * 60 * 24)
}),
getWorkspaceRole: async () => Roles.Workspace.Admin,
...overrides
})
it('succeeds with explicit member role', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthOKResult()
})
it('fails if user not specified', async () => {
const sut = buildSUT()
const result = await sut({
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if user is guest and asking for owner', async () => {
const sut = buildSUT({
getServerRole: async () => Roles.Server.Guest
})
const result = await sut({
userId: 'userId',
projectId: 'projectId',
role: Roles.Stream.Owner
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails w/o role even if admin', async () => {
const sut = buildSUT({
getProjectRole: async () => null,
getServerRole: async () => Roles.Server.Admin
})
const result = await sut({
userId: 'userId',
projectId: 'projectId',
role: Roles.Stream.Reviewer
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails without project role', async () => {
const sut = buildSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds with reviewer role, if permitted', async () => {
const sut = buildSUT({
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await sut({
userId: 'userId',
projectId: 'projectId',
role: Roles.Stream.Reviewer
})
expect(result).toBeAuthOKResult()
})
it('fails with a too restrictive project role', async () => {
const sut = buildSUT({
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
describe('with workspace project', () => {
it('succeeds with implicit project role', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthOKResult()
})
it('fails if workspace role not permissive enough', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => Roles.Workspace.Member,
getProjectRole: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds w/ low workspace role if allowed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => Roles.Workspace.Member,
getProjectRole: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId',
role: Roles.Stream.Reviewer
})
expect(result).toBeAuthOKResult()
})
it('succeeds w/o sso session, if workspace guest w/ explicit project role', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => Roles.Workspace.Guest,
getWorkspaceSsoSession: async () => null,
getProjectRole: async () => Roles.Stream.Contributor
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthOKResult()
})
it('succeeds w/o sso session, if not configured', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthOKResult()
})
it('fails if no sso session, but required', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('fails if sso session expired', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => ({
providerId: 'ssoSessionId',
userId: 'userId',
validUntil: new Date(Date.now() - 1000 * 60 * 60 * 24)
})
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -187,15 +187,6 @@ export const ensureImplicitProjectMemberWithReadAccessFragment: AuthPolicyEnsure
}
if (isAdminOverrideEnabled.value) return ok()
// No god mode, ensure workspace access
const ensuredWorkspaceAccess = await ensureProjectWorkspaceAccessFragment(loaders)({
userId: userId!,
projectId
})
if (ensuredWorkspaceAccess.isErr) {
return err(ensuredWorkspaceAccess.error)
}
// And ensure (implicit/explicit) project role
const ensuredProjectRole = await ensureMinimumProjectRoleFragment(loaders)({
userId: userId!,
@@ -206,5 +197,82 @@ export const ensureImplicitProjectMemberWithReadAccessFragment: AuthPolicyEnsure
return err(ensuredProjectRole.error)
}
// No god mode, ensure workspace access
const ensuredWorkspaceAccess = await ensureProjectWorkspaceAccessFragment(loaders)({
userId: userId!,
projectId
})
if (ensuredWorkspaceAccess.isErr) {
return err(ensuredWorkspaceAccess.error)
}
return ok()
}
/**
* Ensure user has implicit/explicit project membership and write access
*/
export const ensureImplicitProjectMemberWithWriteAccessFragment: AuthPolicyEnsureFragment<
| typeof Loaders.getProject
| typeof Loaders.getEnv
| typeof Loaders.getServerRole
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
MaybeUserContext &
ProjectContext & {
/**
* By default assumes Contributor+ for any writes, but some operations
* may allow for lower roles (e.g. comments)
*/
role?: StreamRoles
},
InstanceType<
| typeof ProjectNotFoundError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ProjectNoAccessError
| typeof WorkspaceNoAccessError
| typeof WorkspaceSsoSessionNoAccessError
>
> =
(loaders) =>
async ({ userId, projectId, role }) => {
const requiredProjectRole = role || Roles.Stream.Contributor
const requiredServerRole =
requiredProjectRole === Roles.Stream.Owner
? Roles.Server.User
: Roles.Server.Guest
// Ensure user is authed
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: requiredServerRole
})
if (ensuredServerRole.isErr) {
return err(ensuredServerRole.error)
}
// And ensure (implicit/explicit) project role
const ensuredProjectRole = await ensureMinimumProjectRoleFragment(loaders)({
userId: userId!,
projectId,
role: requiredProjectRole
})
if (ensuredProjectRole.isErr) {
return err(ensuredProjectRole.error)
}
// Ensure workspace access
const ensuredWorkspaceAccess = await ensureProjectWorkspaceAccessFragment(loaders)({
userId: userId!,
projectId
})
if (ensuredWorkspaceAccess.isErr) {
return err(ensuredWorkspaceAccess.error)
}
return ok()
}
+14 -2
View File
@@ -1,7 +1,7 @@
import { AllAuthCheckContextLoaders } from '../domain/loaders.js'
import { canCreateWorkspaceProjectPolicy } from './workspace/canCreateWorkspaceProject.js'
import { canReadProjectPolicy } from './project/canReadProject.js'
import { canCreateModelPolicy } from './project/canCreateModel.js'
import { canCreateModelPolicy } from './project/model/canCreateModel.js'
import { canMoveToWorkspacePolicy } from './project/canMoveToWorkspace.js'
import { canCreatePersonalProjectPolicy } from './project/canCreatePersonal.js'
import { canUpdateProjectPolicy } from './project/canUpdate.js'
@@ -9,11 +9,23 @@ import { canReadProjectSettingsPolicy } from './project/canReadSettings.js'
import { canReadProjectWebhooksPolicy } from './project/canReadWebhooks.js'
import { canUpdateProjectAllowPublicCommentsPolicy } from './project/canUpdateAllowPublicComments.js'
import { canLeaveProjectPolicy } from './project/canLeave.js'
import { canBroadcastProjectActivityPolicy } from './project/canBroadcastActivity.js'
import { canCreateProjectCommentPolicy } from './project/comment/canCreate.js'
import { canArchiveProjectCommentPolicy } from './project/comment/canArchive.js'
import { canEditProjectCommentPolicy } from './project/comment/canEdit.js'
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
project: {
model: {
canCreate: canCreateModelPolicy(loaders)
},
comment: {
canCreate: canCreateProjectCommentPolicy(loaders),
canArchive: canArchiveProjectCommentPolicy(loaders),
canEdit: canEditProjectCommentPolicy(loaders)
},
canBroadcastActivity: canBroadcastProjectActivityPolicy(loaders),
canRead: canReadProjectPolicy(loaders),
canCreateModel: canCreateModelPolicy(loaders),
canMoveToWorkspace: canMoveToWorkspacePolicy(loaders),
canCreatePersonal: canCreatePersonalProjectPolicy(loaders),
canUpdate: canUpdateProjectPolicy(loaders),
@@ -0,0 +1,265 @@
import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../tests/helpers/types.js'
import { canBroadcastProjectActivityPolicy } from './canBroadcastActivity.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { Roles } from '../../../core/constants.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
describe('canBroadcastProjectActivityPolicy', () => {
const buildSUT = (
overrides?: OverridesOf<typeof canBroadcastProjectActivityPolicy>
) =>
canBroadcastProjectActivityPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getAdminOverrideEnabled: async () => false,
getProjectRole: async () => Roles.Stream.Reviewer,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
const buildWorkspaceSUT = (
overrides?: OverridesOf<typeof canBroadcastProjectActivityPolicy>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date()
}),
...overrides
})
it('succeeds w/ project role', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o project role if public', async () => {
const sut = buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: true
}),
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails if user undefined', async () => {
const sut = buildSUT()
const result = await sut({
userId: undefined,
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if project not found', async () => {
const sut = buildSUT({
getProject: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('fails if user has no project role', async () => {
const sut = buildSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds w/ admin override, even w/o project role', async () => {
const sut = buildSUT({
getAdminOverrideEnabled: async () => true,
getServerRole: async () => Roles.Server.Admin,
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
describe('with workspace project', async () => {
it('succeeds w/ workspace role', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o project & workspace role if public', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => null,
getProjectRole: async () => null,
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: true
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails if user has no workspace role', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceNoAccessError.code
})
})
it('succeeds w/o sso, if not needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o sso, if needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('fails if sso expired', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(Date.now() - 1000)
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,70 @@
import { err, ok } from 'true-myth/result'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { MaybeUserContext, ProjectContext } from '../../domain/context.js'
import { Loaders } from '../../domain/loaders.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
checkIfPubliclyReadableProjectFragment,
ensureImplicitProjectMemberWithReadAccessFragment
} from '../../fragments/projects.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
export const canBroadcastProjectActivityPolicy: AuthPolicy<
| typeof Loaders.getProject
| typeof Loaders.getEnv
| typeof Loaders.getServerRole
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole
| typeof Loaders.getAdminOverrideEnabled,
MaybeUserContext & ProjectContext,
InstanceType<
| typeof ProjectNotFoundError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ProjectNoAccessError
| typeof WorkspaceNoAccessError
| typeof WorkspaceSsoSessionNoAccessError
>
> =
(loaders) =>
async ({ userId, projectId }) => {
// Ensure logged in
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId
})
if (ensuredServerRole.isErr) {
return err(ensuredServerRole.error)
}
// If publicly readable - any authed user can broadcast
const isPubliclyReadable = await checkIfPubliclyReadableProjectFragment(loaders)({
projectId
})
if (isPubliclyReadable.isErr) {
return err(isPubliclyReadable.error)
}
if (isPubliclyReadable.value) return ok()
// Not public. Ensure user has at least implicit membership & read access
const hasReadAccess = await ensureImplicitProjectMemberWithReadAccessFragment(
loaders
)({
userId,
projectId
})
if (hasReadAccess.isErr) {
return err(hasReadAccess.error)
}
return ok()
}
@@ -10,12 +10,13 @@ import {
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
describe('canLeaveProjectPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canLeaveProjectPolicy>) =>
canLeaveProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -33,7 +34,7 @@ describe('canLeaveProjectPolicy', () => {
const buildWorkspaceSUT = (overrides?: OverridesOf<typeof canLeaveProjectPolicy>) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
@@ -10,12 +10,13 @@ import {
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
describe('canReadProjectSettingsPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canReadProjectSettingsPolicy>) =>
canReadProjectSettingsPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -35,7 +36,7 @@ describe('canReadProjectSettingsPolicy', () => {
overrides?: OverridesOf<typeof canReadProjectSettingsPolicy>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
@@ -10,12 +10,13 @@ import {
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { canReadProjectWebhooksPolicy } from './canReadWebhooks.js'
import { getProjectFake } from '../../../tests/fakes.js'
describe('canReadProjectWebhooksPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canReadProjectWebhooksPolicy>) =>
canReadProjectWebhooksPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -35,7 +36,7 @@ describe('canReadProjectWebhooksPolicy', () => {
overrides?: OverridesOf<typeof canReadProjectWebhooksPolicy>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
@@ -9,12 +9,13 @@ import {
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
// Default deps allow test to succeed, this makes it so that we need to override less of them
const buildSUT = (overrides?: Partial<Parameters<typeof canUpdateProjectPolicy>[0]>) =>
canUpdateProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -33,7 +34,7 @@ const buildWorkspaceSUT = (
overrides?: Partial<Parameters<typeof canUpdateProjectPolicy>[0]>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
@@ -2,11 +2,7 @@ import { err, ok } from 'true-myth/result'
import { MaybeUserContext, ProjectContext } from '../../domain/context.js'
import { AuthPolicy } from '../../domain/policies.js'
import { Roles } from '../../../core/constants.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import {
ensureMinimumProjectRoleFragment,
ensureProjectWorkspaceAccessFragment
} from '../../fragments/projects.js'
import { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../fragments/projects.js'
import { Loaders } from '../../domain/loaders.js'
import {
ProjectNoAccessError,
@@ -38,29 +34,16 @@ export const canUpdateProjectPolicy: AuthPolicy<
> =
(loaders) =>
async ({ userId, projectId }) => {
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
// Ensure proper project owner level write access
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
role: Roles.Server.User
})
if (ensuredServerRole.isErr) {
return err(ensuredServerRole.error)
}
const ensuredWorkspaceAccess = await ensureProjectWorkspaceAccessFragment(loaders)({
userId: userId!,
projectId
})
if (ensuredWorkspaceAccess.isErr) {
return err(ensuredWorkspaceAccess.error)
}
const ensuredProjectRole = await ensureMinimumProjectRoleFragment(loaders)({
userId: userId!,
projectId,
role: Roles.Stream.Owner
})
if (ensuredProjectRole.isErr) {
return err(ensuredProjectRole.error)
if (ensuredWriteAccess.isErr) {
return err(ensuredWriteAccess.error)
}
return ok()
@@ -4,6 +4,7 @@ import { canUpdateProjectAllowPublicCommentsPolicy } from './canUpdateAllowPubli
import { parseFeatureFlags } from '../../../environment/index.js'
import { Roles } from '../../../core/constants.js'
import { ProjectNoAccessError } from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
describe('canUpdateProjectAllowPublicCommentsPolicy', () => {
const buildSUT = (
@@ -11,7 +12,7 @@ describe('canUpdateProjectAllowPublicCommentsPolicy', () => {
) =>
canUpdateProjectAllowPublicCommentsPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -39,7 +40,7 @@ describe('canUpdateProjectAllowPublicCommentsPolicy', () => {
it('succeeds if discoverable project', async () => {
const sut = buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: true,
@@ -72,7 +73,7 @@ describe('canUpdateProjectAllowPublicCommentsPolicy', () => {
it('fails if project is neither public nor discoverable', async () => {
const sut = buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -0,0 +1,321 @@
import { describe, expect, it } from 'vitest'
import { canArchiveProjectCommentPolicy } from './canArchive.js'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getCommentFake, getProjectFake } from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
CommentNotFoundError,
ProjectNoAccessError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
describe('canArchiveProjectCommentPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canArchiveProjectCommentPolicy>) =>
canArchiveProjectCommentPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => Roles.Stream.Reviewer,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getComment: getCommentFake({
id: 'comment-id',
authorId: 'user-id',
projectId: 'project-id'
}),
...overrides
})
const buildWorkspaceSUT = (
overrides?: OverridesOf<typeof canArchiveProjectCommentPolicy>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date()
}),
...overrides
})
it('can archive own comment', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can't archive own comment w/o project roles", async () => {
const sut = buildSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it("can archive others' comments if owner", async () => {
const sut = buildSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getProjectRole: async () => Roles.Stream.Owner
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can't archive others' comments if not owner", async () => {
const sut = buildSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getProjectRole: async () => Roles.Stream.Contributor
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails if user not defined', async () => {
const sut = buildSUT()
const result = await sut({
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if comment not found', async () => {
const sut = buildSUT({
getComment: async () => null
})
const result = await sut({
userId: 'user-id',
commentId: 'aaaaaaaaaaaa',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: CommentNotFoundError.code
})
})
describe('with workspace project', () => {
it('can archive own comment', async () => {
const sut = buildWorkspaceSUT()
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can archive others' comments if workspace admin", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getWorkspaceRole: async () => Roles.Workspace.Admin
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can archive others' comments as admin w/o sso, if not needed", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoSession: async () => null,
getWorkspaceSsoProvider: async () => null
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can arhive others' comments if explicit project owner", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getProjectRole: async () => Roles.Stream.Owner
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can't archive others' comments if not owner", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getWorkspaceRole: async () => Roles.Workspace.Member
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it("can't archive others' comments as owner, if no sso session", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it("can't archive others' comments as owner, if sso session expired", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(0)
})
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,75 @@
import { err, ok } from 'true-myth/result'
import { AuthPolicy } from '../../../domain/policies.js'
import {
CommentContext,
MaybeUserContext,
ProjectContext
} from '../../../domain/context.js'
import { Loaders } from '../../../domain/loaders.js'
import {
CommentNotFoundError,
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../../fragments/projects.js'
import { Roles } from '../../../../core/constants.js'
import { canCreateProjectCommentPolicy } from './canCreate.js'
export const canArchiveProjectCommentPolicy: AuthPolicy<
| typeof Loaders.getServerRole
| typeof Loaders.getComment
| typeof Loaders.getProject
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
MaybeUserContext & CommentContext & ProjectContext,
InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof WorkspaceSsoSessionNoAccessError
| typeof CommentNotFoundError
>
> =
(loaders) =>
async ({ userId, commentId, projectId }) => {
// Includes canCreate check (checks general comment write access,
// cause just owning a comment is not enough, if you've been banned from it)
const canCreate = await canCreateProjectCommentPolicy(loaders)({
userId,
projectId
})
if (canCreate.isErr) {
return err(canCreate.error)
}
// Check that comment exists
const comment = await loaders.getComment({ commentId, projectId })
if (!comment) return err(new CommentNotFoundError())
// If user is owner, no extra checks necessary
if (comment.authorId === userId) return ok()
// Otherwise Ensure proper project owner level write access
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId,
role: Roles.Stream.Owner
})
if (ensuredWriteAccess.isErr) {
return err(ensuredWriteAccess.error)
}
return ok()
}
@@ -0,0 +1,294 @@
import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { canCreateProjectCommentPolicy } from './canCreate.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getProjectFake } from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
describe('canCreateProjectCommentPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canCreateProjectCommentPolicy>) =>
canCreateProjectCommentPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => Roles.Stream.Reviewer,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
const buildWorkspaceSUT = (
overrides?: OverridesOf<typeof canCreateProjectCommentPolicy>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date()
}),
...overrides
})
it('succeeds w/ explicit project role', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o project role if public and public comments allowed', async () => {
const sut = buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: true,
allowPublicComments: true
}),
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o project role', async () => {
const sut = buildSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails w/o project role if public, but no public comments allowed', async () => {
const sut = buildSUT({
getProjectRole: async () => null,
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: true,
allowPublicComments: false
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails if user undefined', async () => {
const sut = buildSUT()
const result = await sut({
userId: undefined,
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if project not found', async () => {
const sut = buildSUT({
getProject: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('fails w/o project role, even if admin', async () => {
const sut = buildSUT({
getProjectRole: async () => null,
getServerRole: async () => Roles.Server.Admin
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
describe('with workspace project', () => {
it('succeeds w/ implicit project role', async () => {
const sut = buildWorkspaceSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/ explicit project role, if guest', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => Roles.Stream.Reviewer,
getWorkspaceRole: async () => Roles.Workspace.Guest
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o project role, if only guest', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => null,
getWorkspaceRole: async () => Roles.Workspace.Guest
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds w/o session, if guest w/ explicit role', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => Roles.Stream.Reviewer,
getWorkspaceRole: async () => Roles.Workspace.Guest,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o session, if not needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null,
getWorkspaceSsoProvider: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o session, if needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('fails w/ expired session', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(Date.now() - 1000)
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,66 @@
import { err, ok } from 'true-myth/result'
import { MaybeUserContext, ProjectContext } from '../../../domain/context.js'
import { AuthPolicy } from '../../../domain/policies.js'
import { ensureMinimumServerRoleFragment } from '../../../fragments/server.js'
import { Loaders } from '../../../domain/loaders.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../../fragments/projects.js'
import { Roles } from '../../../../core/constants.js'
export const canCreateProjectCommentPolicy: AuthPolicy<
| typeof Loaders.getProject
| typeof Loaders.getServerRole
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
MaybeUserContext & ProjectContext,
InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof WorkspaceSsoSessionNoAccessError
>
> =
(loaders) =>
async ({ userId, projectId }) => {
// Ensure server access
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId
})
if (ensuredServerRole.isErr) {
return err(ensuredServerRole.error)
}
// Check if public commenting enabled
const project = await loaders.getProject({ projectId })
if (!project) return err(new ProjectNotFoundError())
const allowPublicCommenting =
(project.isPublic || project.isDiscoverable) && project.allowPublicComments
if (allowPublicCommenting) return ok()
// Not public, ensure proper project write access
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId,
role: Roles.Stream.Reviewer
})
if (ensuredWriteAccess.isErr) {
return err(ensuredWriteAccess.error)
}
return ok()
}
@@ -0,0 +1,377 @@
import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getCommentFake, getProjectFake } from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
CommentNoAccessError,
CommentNotFoundError,
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { canEditProjectCommentPolicy } from './canEdit.js'
describe('canEditProjectCommentPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canEditProjectCommentPolicy>) =>
canEditProjectCommentPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => Roles.Stream.Reviewer,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getComment: getCommentFake({
id: 'comment-id',
projectId: 'project-id',
authorId: 'user-id'
}),
...overrides
})
const buildWorkspaceSUT = (
overrides?: OverridesOf<typeof canEditProjectCommentPolicy>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date()
}),
...overrides
})
it('succeeds w/ explicit project role', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o project role if public and public comments allowed', async () => {
const sut = buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: true,
allowPublicComments: true
}),
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('fails if not the author, even if admin', async () => {
const sut = buildSUT({
getComment: getCommentFake({
id: 'comment-id',
projectId: 'project-id',
authorId: 'other-user-id'
}),
getServerRole: async () => Roles.Server.Admin,
getProjectRole: async () => Roles.Stream.Owner
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: CommentNoAccessError.code
})
})
it('fails if comment not found', async () => {
const sut = buildSUT({
getComment: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: CommentNotFoundError.code
})
})
it('fails w/o project role', async () => {
const sut = buildSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails w/o project role if public, but no public comments allowed', async () => {
const sut = buildSUT({
getProjectRole: async () => null,
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: true,
allowPublicComments: false
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails if user undefined', async () => {
const sut = buildSUT()
const result = await sut({
userId: undefined,
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if project not found', async () => {
const sut = buildSUT({
getProject: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('fails w/o project role, even if admin', async () => {
const sut = buildSUT({
getProjectRole: async () => null,
getServerRole: async () => Roles.Server.Admin
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
describe('with workspace project', () => {
it('succeeds w/ implicit project role', async () => {
const sut = buildWorkspaceSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/ explicit project role, if guest', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => Roles.Stream.Reviewer,
getWorkspaceRole: async () => Roles.Workspace.Guest
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('fails if not the author, even if admin', async () => {
const sut = buildSUT({
getComment: getCommentFake({
id: 'comment-id',
projectId: 'project-id',
authorId: 'other-user-id'
}),
getServerRole: async () => Roles.Server.Admin,
getProjectRole: async () => Roles.Stream.Owner,
getWorkspaceRole: async () => Roles.Workspace.Admin
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: CommentNoAccessError.code
})
})
it('fails w/o project role, if only guest', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => null,
getWorkspaceRole: async () => Roles.Workspace.Guest
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds w/o session, if guest w/ explicit role', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => Roles.Stream.Reviewer,
getWorkspaceRole: async () => Roles.Workspace.Guest,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o session, if not needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null,
getWorkspaceSsoProvider: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('fails w/o session, if needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('fails w/ expired session', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(Date.now() - 1000)
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,66 @@
import { err, ok } from 'true-myth/result'
import { AuthPolicy } from '../../../domain/policies.js'
import {
CommentContext,
MaybeUserContext,
ProjectContext
} from '../../../domain/context.js'
import { Loaders } from '../../../domain/loaders.js'
import {
CommentNoAccessError,
CommentNotFoundError,
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { canCreateProjectCommentPolicy } from './canCreate.js'
export const canEditProjectCommentPolicy: AuthPolicy<
| typeof Loaders.getServerRole
| typeof Loaders.getComment
| typeof Loaders.getProject
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
MaybeUserContext & CommentContext & ProjectContext,
InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof WorkspaceSsoSessionNoAccessError
| typeof CommentNotFoundError
| typeof CommentNoAccessError
>
> =
(loaders) =>
async ({ userId, commentId, projectId }) => {
// Includes canCreate check
const canCreate = await canCreateProjectCommentPolicy(loaders)({
userId,
projectId
})
if (canCreate.isErr) {
return err(canCreate.error)
}
// Check that comment exists
const comment = await loaders.getComment({ commentId, projectId })
if (!comment) return err(new CommentNotFoundError())
// Disallow if user is not the author
if (comment.authorId !== userId) {
return err(
new CommentNoAccessError('You do not have access to edit this comment')
)
}
return ok()
}
@@ -1,32 +1,31 @@
import cryptoRandomString from 'crypto-random-string'
import { assert, describe, expect, it } from 'vitest'
import { canCreateModelPolicy } from './canCreateModel.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { Roles } from '../../../core/constants.js'
import { Workspace } from '../../domain/workspaces/types.js'
import { WorkspacePlan } from '../../../workspaces/index.js'
import { Project } from '../../domain/projects/types.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { Roles } from '../../../../core/constants.js'
import { Workspace } from '../../../domain/workspaces/types.js'
import { WorkspacePlan } from '../../../../workspaces/index.js'
import { Project } from '../../../domain/projects/types.js'
import {
ProjectNoAccessError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceLimitsReachedError,
WorkspaceNoAccessError
} from '../../domain/authErrors.js'
} from '../../../domain/authErrors.js'
import { getProjectFake } from '../../../../tests/fakes.js'
const buildCanCreateModelPolicy = (
overrides?: Partial<Parameters<typeof canCreateModelPolicy>[0]>
) =>
canCreateModelPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => {
return {
id: cryptoRandomString({ length: 9 }),
isPublic: false,
isDiscoverable: false,
workspaceId: cryptoRandomString({ length: 9 })
}
},
getProject: getProjectFake({
id: cryptoRandomString({ length: 9 }),
isPublic: false,
isDiscoverable: false,
workspaceId: cryptoRandomString({ length: 9 })
}),
getProjectRole: async () => {
return Roles.Stream.Contributor
},
@@ -8,17 +8,17 @@ import {
ServerNoSessionError,
ServerNoAccessError,
WorkspaceReadOnlyError
} from '../../domain/authErrors.js'
import { MaybeUserContext, ProjectContext } from '../../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { AuthPolicy } from '../../domain/policies.js'
import { Roles } from '../../../core/constants.js'
import { isWorkspacePlanStatusReadOnly } from '../../../workspaces/index.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
} from '../../../domain/authErrors.js'
import { MaybeUserContext, ProjectContext } from '../../../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../../../domain/loaders.js'
import { AuthPolicy } from '../../../domain/policies.js'
import { Roles } from '../../../../core/constants.js'
import { isWorkspacePlanStatusReadOnly } from '../../../../workspaces/index.js'
import { ensureMinimumServerRoleFragment } from '../../../fragments/server.js'
import {
ensureMinimumProjectRoleFragment,
ensureProjectWorkspaceAccessFragment
} from '../../fragments/projects.js'
} from '../../../fragments/projects.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
+9 -1
View File
@@ -1,5 +1,6 @@
import { merge } from 'lodash'
import { Project } from '../authz/domain/projects/types.js'
import { Comment } from '../authz/domain/comments/types.js'
import { nanoid } from 'nanoid'
export const fakeGetFactory =
@@ -16,5 +17,12 @@ export const getProjectFake = fakeGetFactory<Project>({
id: nanoid(10),
isPublic: false,
isDiscoverable: false,
workspaceId: null
workspaceId: null,
allowPublicComments: false
})
export const getCommentFake = fakeGetFactory<Comment>({
id: nanoid(10),
authorId: nanoid(10),
projectId: nanoid(10)
})