diff --git a/packages/frontend-2/pages/projects/[id]/index.vue b/packages/frontend-2/pages/projects/[id]/index.vue index b07a76dbc..5597d1841 100644 --- a/packages/frontend-2/pages/projects/[id]/index.vue +++ b/packages/frontend-2/pages/projects/[id]/index.vue @@ -104,6 +104,9 @@ graphql(` canReadSettings { ...FullPermissionCheckResult } + canReadAccIntegrationSettings { + ...FullPermissionCheckResult + } canUpdate { ...FullPermissionCheckResult } @@ -179,6 +182,9 @@ const modelCount = computed(() => project.value?.modelCount.totalCount) const commentCount = computed(() => project.value?.commentThreadCount.totalCount) const canReadSettings = computed(() => project.value?.permissions.canReadSettings) +const canReadAccIntegrationSettings = computed( + () => project.value?.permissions.canReadAccIntegrationSettings +) const canUpdate = computed(() => project.value?.permissions.canUpdate) const hasRole = computed(() => project.value?.role) const teamUsers = computed(() => project.value?.team.map((t) => t.user) || []) @@ -254,7 +260,7 @@ const pageTabItems = computed((): LayoutPageTabItem[] => { }) } - if (isAccEnabled.value) { + if (isAccEnabled.value && canReadAccIntegrationSettings.value?.authorized) { items.push({ title: 'ACC', id: 'acc' diff --git a/packages/server/assets/core/typedefs/permissions.graphql b/packages/server/assets/core/typedefs/permissions.graphql index 72b5a17be..0937fe5d1 100644 --- a/packages/server/assets/core/typedefs/permissions.graphql +++ b/packages/server/assets/core/typedefs/permissions.graphql @@ -10,6 +10,7 @@ type ProjectPermissionChecks { canDelete: PermissionCheckResult! canUpdateAllowPublicComments: PermissionCheckResult! canReadSettings: PermissionCheckResult! + canReadAccIntegrationSettings: PermissionCheckResult! canReadWebhooks: PermissionCheckResult! canLeave: PermissionCheckResult! canRequestRender: PermissionCheckResult! diff --git a/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql index 7e10b6678..84bbebf56 100644 --- a/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql @@ -154,6 +154,7 @@ extend type Project { } enum WorkspaceFeatureName { + accIntegration domainBasedSecurityPolicies oidcSso hideSpeckleBranding diff --git a/packages/server/modules/acc/graph/resolvers/accSyncItems.ts b/packages/server/modules/acc/graph/resolvers/accSyncItems.ts index d84dc353b..37cdda96b 100644 --- a/packages/server/modules/acc/graph/resolvers/accSyncItems.ts +++ b/packages/server/modules/acc/graph/resolvers/accSyncItems.ts @@ -74,6 +74,13 @@ const resolvers: Resolvers = { async create(_parent, args, ctx) { const { input } = args + const authResult = await ctx.authPolicies.project.canUpdateAccIntegrationSettings( + { + userId: ctx.userId, + projectId: input.projectId + } + ) + throwIfAuthNotOk(authResult) throwIfResourceAccessNotAllowed({ resourceId: input.projectId, resourceAccessRules: ctx.resourceAccessRules, @@ -133,6 +140,13 @@ const resolvers: Resolvers = { async update(_parent, args, ctx) { const { input } = args + const authResult = await ctx.authPolicies.project.canUpdateAccIntegrationSettings( + { + userId: ctx.userId, + projectId: input.projectId + } + ) + throwIfAuthNotOk(authResult) throwIfResourceAccessNotAllowed({ resourceId: input.projectId, resourceAccessRules: ctx.resourceAccessRules, @@ -150,6 +164,13 @@ const resolvers: Resolvers = { async delete(_parent, args, ctx) { const { input } = args + const authResult = await ctx.authPolicies.project.canUpdateAccIntegrationSettings( + { + userId: ctx.userId, + projectId: input.projectId + } + ) + throwIfAuthNotOk(authResult) throwIfResourceAccessNotAllowed({ resourceId: input.projectId, resourceAccessRules: ctx.resourceAccessRules, @@ -176,6 +197,11 @@ const resolvers: Resolvers = { async accSyncItems(parent, args, ctx) { const { cursor = null, limit = null } = args + const authResult = await ctx.authPolicies.project.canReadAccIntegrationSettings({ + userId: ctx.userId, + projectId: parent.id + }) + throwIfAuthNotOk(authResult) throwIfResourceAccessNotAllowed({ resourceId: parent.id, resourceAccessRules: ctx.resourceAccessRules, @@ -196,6 +222,11 @@ const resolvers: Resolvers = { async accSyncItem(parent, args, ctx) { const { id } = args + const authResult = await ctx.authPolicies.project.canReadAccIntegrationSettings({ + userId: ctx.userId, + projectId: parent.id + }) + throwIfAuthNotOk(authResult) throwIfResourceAccessNotAllowed({ resourceId: parent.id, resourceAccessRules: ctx.resourceAccessRules, diff --git a/packages/server/modules/acc/graph/resolvers/permissions.ts b/packages/server/modules/acc/graph/resolvers/permissions.ts new file mode 100644 index 000000000..6975b7aba --- /dev/null +++ b/packages/server/modules/acc/graph/resolvers/permissions.ts @@ -0,0 +1,16 @@ +import type { Resolvers } from '@/modules/core/graph/generated/graphql' +import { Authz } from '@speckle/shared' + +const resolvers: Resolvers = { + ProjectPermissionChecks: { + async canReadAccIntegrationSettings(parent, _args, ctx) { + const authResult = await ctx.authPolicies.project.canReadAccIntegrationSettings({ + userId: ctx.userId, + projectId: parent.projectId + }) + return Authz.toGraphqlResult(authResult) + } + } +} + +export default resolvers diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index a815e4c02..385bd93e1 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -2915,6 +2915,7 @@ export type ProjectPermissionChecks = { canMoveToWorkspace: PermissionCheckResult; canPublish: PermissionCheckResult; canRead: PermissionCheckResult; + canReadAccIntegrationSettings: PermissionCheckResult; canReadEmbedTokens: PermissionCheckResult; canReadSettings: PermissionCheckResult; canReadWebhooks: PermissionCheckResult; @@ -4992,6 +4993,7 @@ export type WorkspaceEmbedOptions = { }; export const WorkspaceFeatureName = { + AccIntegration: 'accIntegration', DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies', ExclusiveMembership: 'exclusiveMembership', HideSpeckleBranding: 'hideSpeckleBranding', @@ -7341,6 +7343,7 @@ export type ProjectPermissionChecksResolvers>; canPublish?: Resolver; canRead?: Resolver; + canReadAccIntegrationSettings?: Resolver; canReadEmbedTokens?: Resolver; canReadSettings?: Resolver; canReadWebhooks?: Resolver; diff --git a/packages/server/modules/shared/helpers/errorHelper.ts b/packages/server/modules/shared/helpers/errorHelper.ts index e9007c3a4..ecb0008e2 100644 --- a/packages/server/modules/shared/helpers/errorHelper.ts +++ b/packages/server/modules/shared/helpers/errorHelper.ts @@ -1,3 +1,4 @@ +import { AccModuleDisabledError } from '@/modules/acc/errors/acc' import { AutomateModuleDisabledError } from '@/modules/core/errors/automate' import { StreamNotFoundError } from '@/modules/core/errors/stream' import { WorkspacesModuleDisabledError } from '@/modules/core/errors/workspaces' @@ -56,6 +57,8 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => { return new WorkspacesModuleDisabledError() case Authz.AutomateNotEnabledError.code: return new AutomateModuleDisabledError() + case Authz.AccIntegrationNotEnabledError.code: + return new AccModuleDisabledError() case Authz.ProjectLastOwnerError.code: case Authz.ReservedModelNotDeletableError.code: return new BadRequestError(e.message) diff --git a/packages/shared/src/authz/domain/authErrors.ts b/packages/shared/src/authz/domain/authErrors.ts index 15ffc18de..69cb0764a 100644 --- a/packages/shared/src/authz/domain/authErrors.ts +++ b/packages/shared/src/authz/domain/authErrors.ts @@ -196,6 +196,11 @@ export const AutomateFunctionNotCreatorError = defineAuthError({ message: 'You are not the function creator and cannot make changes to it.' }) +export const AccIntegrationNotEnabledError = defineAuthError({ + code: 'AccIntegrationNotEnabled', + message: 'The ACC Integration is not enabled on this server or project' +}) + // Resolve all exported error types export type AllAuthErrors = ValueOf<{ [key in keyof typeof import('./authErrors.js')]: typeof import('./authErrors.js')[key] extends new ( diff --git a/packages/shared/src/authz/policies/index.ts b/packages/shared/src/authz/policies/index.ts index b43c7f91b..df21856c7 100644 --- a/packages/shared/src/authz/policies/index.ts +++ b/packages/shared/src/authz/policies/index.ts @@ -33,6 +33,7 @@ import { canCreateWorkspacePolicy } from './workspace/canCreateWorkspace.js' import { canUseWorkspacePlanFeature } from './workspace/canUseWorkspacePlanFeature.js' import { canEditFunctionPolicy } from './automate/function/canEditFunction.js' import { canUpdateEmbedTokensPolicy } from './project/canUpdateEmbedTokens.js' +import { canReadAccIntegrationSettingsPolicy } from './project/canReadAccIntegrationSettings.js' export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ automate: { @@ -77,7 +78,9 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ canPublish: canPublishPolicy(loaders), canLoad: canLoadPolicy(loaders), canReadEmbedTokens: canUpdateEmbedTokensPolicy(loaders), - canUpdateEmbedTokens: canUpdateEmbedTokensPolicy(loaders) + canUpdateEmbedTokens: canUpdateEmbedTokensPolicy(loaders), + canReadAccIntegrationSettings: canReadAccIntegrationSettingsPolicy(loaders), + canUpdateAccIntegrationSettings: canReadAccIntegrationSettingsPolicy(loaders) }, workspace: { canCreateProject: canCreateWorkspaceProjectPolicy(loaders), diff --git a/packages/shared/src/authz/policies/project/canReadAccIntegrationSettings.spec.ts b/packages/shared/src/authz/policies/project/canReadAccIntegrationSettings.spec.ts new file mode 100644 index 000000000..0a2f510ae --- /dev/null +++ b/packages/shared/src/authz/policies/project/canReadAccIntegrationSettings.spec.ts @@ -0,0 +1,124 @@ +import cryptoRandomString from 'crypto-random-string' +import { Roles } from '../../../core/constants.js' +import { parseFeatureFlags } from '../../../environment/index.js' +import { OverridesOf } from '../../../tests/helpers/types.js' +import { canReadAccIntegrationSettingsPolicy } from './canReadAccIntegrationSettings.js' +import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js' +import { assert, describe, expect, it } from 'vitest' +import { + AccIntegrationNotEnabledError, + ProjectNoAccessError, + WorkspacePlanNoFeatureAccessError +} from '../../domain/authErrors.js' + +const buildSUT = ( + overrides?: OverridesOf +) => { + const workspaceId = cryptoRandomString({ length: 9 }) + + return canReadAccIntegrationSettingsPolicy({ + getEnv: async () => parseFeatureFlags({ FF_ACC_INTEGRATION_ENABLED: 'true' }), + getServerRole: async () => { + return Roles.Server.User + }, + getAdminOverrideEnabled: async () => { + return true + }, + getProject: getProjectFake({ + id: 'project-id', + workspaceId + }), + getProjectRole: async () => { + return Roles.Stream.Contributor + }, + getWorkspace: getWorkspaceFake({ + id: workspaceId + }), + getWorkspaceRole: async () => { + return Roles.Workspace.Member + }, + getWorkspaceSsoProvider: async () => { + return null + }, + getWorkspaceSsoSession: async () => { + assert.fail() + }, + getWorkspacePlan: async () => { + return { + status: 'valid', + workspaceId, + name: 'enterprise', + createdAt: new Date(), + updatedAt: new Date() + } + }, + ...overrides + }) +} + +const buildArgs = () => ({ + userId: cryptoRandomString({ length: 9 }), + projectId: cryptoRandomString({ length: 9 }) +}) + +describe('canReadAccIntegrationSettings returns a function, that', () => { + it('requires the ACC integration to be enabled', async () => { + const result = await buildSUT({ + getEnv: async () => parseFeatureFlags({ FF_ACC_INTEGRATION_ENABLED: 'false' }) + })(buildArgs()) + + expect(result).toBeAuthErrorResult({ + code: AccIntegrationNotEnabledError.code + }) + }) + it('requires the project to belong to a workspace', async () => { + const result = await buildSUT({ + getProject: getProjectFake({ + id: 'project-id' + }) + })(buildArgs()) + + expect(result).toBeAuthErrorResult({ + code: AccIntegrationNotEnabledError.code + }) + }) + it('requires the given user to have read access to the project', async () => { + const result = await buildSUT({ + getProjectRole: async () => null + })(buildArgs()) + + expect(result).toBeAuthErrorResult({ + code: ProjectNoAccessError.code + }) + }) + it('requires the workspace to have an active plan', async () => { + const result = await buildSUT({ + getWorkspacePlan: async () => null + })(buildArgs()) + + expect(result).toBeAuthErrorResult({ + code: WorkspacePlanNoFeatureAccessError.code + }) + }) + it('requires the workspace plan to have access to the ACC integration feature', async () => { + const result = await buildSUT({ + getWorkspacePlan: async () => { + return { + status: 'valid', + workspaceId: cryptoRandomString({ length: 9 }), + name: 'free', + createdAt: new Date(), + updatedAt: new Date() + } + } + })(buildArgs()) + + expect(result).toBeAuthErrorResult({ + code: WorkspacePlanNoFeatureAccessError.code + }) + }) + it('allows enterprise plans to access the ACC integration feature', async () => { + const result = await buildSUT({})(buildArgs()) + expect(result).toBeAuthOKResult() + }) +}) diff --git a/packages/shared/src/authz/policies/project/canReadAccIntegrationSettings.ts b/packages/shared/src/authz/policies/project/canReadAccIntegrationSettings.ts new file mode 100644 index 000000000..e826398fc --- /dev/null +++ b/packages/shared/src/authz/policies/project/canReadAccIntegrationSettings.ts @@ -0,0 +1,87 @@ +import { err, ok } from 'true-myth/result' +import { + ServerNoAccessError, + ServerNoSessionError, + ServerNotEnoughPermissionsError, + ProjectNotFoundError, + ProjectNoAccessError, + WorkspaceNoAccessError, + WorkspaceSsoSessionNoAccessError, + ProjectNotEnoughPermissionsError, + WorkspaceNotEnoughPermissionsError, + WorkspacePlanNoFeatureAccessError, + AccIntegrationNotEnabledError +} from '../../domain/authErrors.js' +import { MaybeUserContext, ProjectContext } from '../../domain/context.js' +import { Loaders } from '../../domain/loaders.js' +import { AuthPolicy } from '../../domain/policies.js' +import { ensureImplicitProjectMemberWithReadAccessFragment } from '../../fragments/projects.js' +import { + WorkspacePlanFeatures, + workspacePlanHasAccessToFeature +} from '../../../workspaces/index.js' + +type PolicyLoaderKeys = + | typeof Loaders.getEnv + | typeof Loaders.getServerRole + | typeof Loaders.getAdminOverrideEnabled + | typeof Loaders.getProject + | typeof Loaders.getWorkspaceRole + | typeof Loaders.getWorkspace + | typeof Loaders.getWorkspaceSsoProvider + | typeof Loaders.getWorkspaceSsoSession + | typeof Loaders.getProjectRole + | typeof Loaders.getWorkspacePlan + +type PolicyArgs = MaybeUserContext & ProjectContext + +type PolicyErrors = InstanceType< + | typeof ServerNoAccessError + | typeof ServerNoSessionError + | typeof ServerNotEnoughPermissionsError + | typeof ProjectNotFoundError + | typeof ProjectNoAccessError + | typeof WorkspaceNoAccessError + | typeof WorkspaceSsoSessionNoAccessError + | typeof ProjectNotEnoughPermissionsError + | typeof WorkspaceNotEnoughPermissionsError + | typeof WorkspacePlanNoFeatureAccessError + | typeof AccIntegrationNotEnabledError +> + +export const canReadAccIntegrationSettingsPolicy: AuthPolicy< + PolicyLoaderKeys, + PolicyArgs, + PolicyErrors +> = + (loaders) => + async ({ userId, projectId }) => { + const env = await loaders.getEnv() + const project = await loaders.getProject({ projectId }) + + if (!env.FF_ACC_INTEGRATION_ENABLED || !project?.workspaceId) { + return err(new AccIntegrationNotEnabledError()) + } + + const ensuredProjectRole = await ensureImplicitProjectMemberWithReadAccessFragment( + loaders + )({ + userId, + projectId + }) + if (ensuredProjectRole.isErr) { + return err(ensuredProjectRole.error) + } + + const workspacePlan = await loaders.getWorkspacePlan({ + workspaceId: project.workspaceId + }) + if (!workspacePlan) return err(new WorkspacePlanNoFeatureAccessError()) + const canUseFeature = workspacePlanHasAccessToFeature({ + plan: workspacePlan.name, + feature: WorkspacePlanFeatures.AccIntegration + }) + if (!canUseFeature) return err(new WorkspacePlanNoFeatureAccessError()) + + return ok() + } diff --git a/packages/shared/src/workspaces/helpers/features.ts b/packages/shared/src/workspaces/helpers/features.ts index 82629c5dd..410a9a3a3 100644 --- a/packages/shared/src/workspaces/helpers/features.ts +++ b/packages/shared/src/workspaces/helpers/features.ts @@ -22,7 +22,8 @@ export const WorkspacePlanFeatures = { CustomDataRegion: 'workspaceDataRegionSpecificity', HideSpeckleBranding: 'hideSpeckleBranding', ExclusiveMembership: 'exclusiveMembership', - EmbedPrivateProjects: 'embedPrivateProjects' + EmbedPrivateProjects: 'embedPrivateProjects', + AccIntegration: 'accIntegration' } export type WorkspacePlanFeatures = @@ -62,6 +63,10 @@ export const WorkspacePlanFeaturesMetadata = ({ [WorkspacePlanFeatures.EmbedPrivateProjects]: { displayName: 'Embed private projects', description: 'Embed projects with visibility set to private or workspace' + }, + [WorkspacePlanFeatures.AccIntegration]: { + displayName: 'ACC connector', + description: 'Configure automatic import of ACC assets into workspace projects' } }) satisfies Record< WorkspacePlanFeatures, @@ -168,7 +173,8 @@ export const WorkspaceUnpaidPlanConfigs: { WorkspacePlanFeatures.SSO, WorkspacePlanFeatures.CustomDataRegion, WorkspacePlanFeatures.HideSpeckleBranding, - WorkspacePlanFeatures.ExclusiveMembership + WorkspacePlanFeatures.ExclusiveMembership, + WorkspacePlanFeatures.AccIntegration ], limits: unlimited }, @@ -180,7 +186,8 @@ export const WorkspaceUnpaidPlanConfigs: { WorkspacePlanFeatures.SSO, WorkspacePlanFeatures.CustomDataRegion, WorkspacePlanFeatures.HideSpeckleBranding, - WorkspacePlanFeatures.ExclusiveMembership + WorkspacePlanFeatures.ExclusiveMembership, + WorkspacePlanFeatures.AccIntegration ], limits: unlimited },