fix(acc): policy and usage in FE

This commit is contained in:
Charles Driesler
2025-08-05 00:52:16 +01:00
parent 4b96a9faeb
commit 6da00d524c
12 changed files with 292 additions and 5 deletions
@@ -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'
@@ -10,6 +10,7 @@ type ProjectPermissionChecks {
canDelete: PermissionCheckResult!
canUpdateAllowPublicComments: PermissionCheckResult!
canReadSettings: PermissionCheckResult!
canReadAccIntegrationSettings: PermissionCheckResult!
canReadWebhooks: PermissionCheckResult!
canLeave: PermissionCheckResult!
canRequestRender: PermissionCheckResult!
@@ -154,6 +154,7 @@ extend type Project {
}
enum WorkspaceFeatureName {
accIntegration
domainBasedSecurityPolicies
oidcSso
hideSpeckleBranding
@@ -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,
@@ -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
@@ -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<ContextType = GraphQLContext, Paren
canMoveToWorkspace?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType, Partial<ProjectPermissionChecksCanMoveToWorkspaceArgs>>;
canPublish?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canRead?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canReadAccIntegrationSettings?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canReadEmbedTokens?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canReadSettings?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canReadWebhooks?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
@@ -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)
@@ -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 (
+4 -1
View File
@@ -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),
@@ -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<typeof canReadAccIntegrationSettingsPolicy>
) => {
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()
})
})
@@ -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()
}
@@ -22,7 +22,8 @@ export const WorkspacePlanFeatures = <const>{
CustomDataRegion: 'workspaceDataRegionSpecificity',
HideSpeckleBranding: 'hideSpeckleBranding',
ExclusiveMembership: 'exclusiveMembership',
EmbedPrivateProjects: 'embedPrivateProjects'
EmbedPrivateProjects: 'embedPrivateProjects',
AccIntegration: 'accIntegration'
}
export type WorkspacePlanFeatures =
@@ -62,6 +63,10 @@ export const WorkspacePlanFeaturesMetadata = (<const>{
[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
},