feat(authz): automate policies (#4491)

This commit is contained in:
Chuck Driesler
2025-04-18 10:03:54 +01:00
committed by GitHub
parent 90641d20de
commit d7aa0196fc
25 changed files with 1128 additions and 108 deletions
@@ -60,7 +60,7 @@ import {
} from '~/lib/projects/graphql/queries'
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { Roles, type Nullable } from '@speckle/shared'
import type { Nullable } from '@speckle/shared'
import type { AutomateOnboardingAction } from '~/components/project/page/automations/EmptyState.vue'
const route = useRoute()
@@ -88,12 +88,27 @@ const workspace = computed(() => result.value?.project?.workspace ?? undefined)
const workspaceFunctionCount = computed(
() => result.value?.project.workspace?.automateFunctions.totalCount ?? 0
)
const canCreateAutomation = computed(
() => result.value?.project?.permissions.canCreateAutomation
)
const hiddenActions = computed<AutomateOnboardingAction[]>(() => {
return workspaceFunctionCount.value > 0 ? [] : ['view-functions']
})
const disabledActions = computed<
{ action: AutomateOnboardingAction; reason: string }[]
>(() => {
if (!canCreateAutomation.value?.authorized) {
return [
{
action: 'create-automation',
reason:
canCreateAutomation.value?.message ??
'You are not authorized to create an automation.'
}
]
}
if (workspaceFunctionCount.value === 0) {
return [
{
@@ -103,14 +118,6 @@ const disabledActions = computed<
}
]
}
if (result.value?.project?.role !== Roles.Stream.Owner) {
return [
{
action: 'create-automation',
reason: 'Only project owners can create new automations.'
}
]
}
if ((result.value?.project?.models?.items.length || 0) === 0) {
return [
{
@@ -266,7 +266,7 @@ type Documents = {
"\n query ProjectModelVersions(\n $projectId: String!\n $modelId: String!\n $versionsCursor: String\n ) {\n project(id: $projectId) {\n id\n ...ProjectModelPageVersionsPagination\n }\n }\n": typeof types.ProjectModelVersionsDocument,
"\n query ProjectModelsPage($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectModelsPageHeader_Project\n ...ProjectModelsPageResults_Project\n }\n }\n": typeof types.ProjectModelsPageDocument,
"\n query ProjectDiscussionsPage($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectDiscussionsPageHeader_Project\n ...ProjectDiscussionsPageResults_Project\n }\n }\n": typeof types.ProjectDiscussionsPageDocument,
"\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n": typeof types.ProjectAutomationsTabDocument,
"\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n permissions {\n canCreateAutomation {\n ...FullPermissionCheckResult\n }\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n": typeof types.ProjectAutomationsTabDocument,
"\n query ProjectAutomationsTabAutomationsPagination(\n $projectId: String!\n $search: String = null\n $cursor: String = null\n ) {\n project(id: $projectId) {\n id\n automations(filter: $search, cursor: $cursor, limit: 5) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n }\n }\n }\n": typeof types.ProjectAutomationsTabAutomationsPaginationDocument,
"\n query ProjectAutomationPage($projectId: String!, $automationId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageAutomationPage_Project\n automation(id: $automationId) {\n id\n ...ProjectPageAutomationPage_Automation\n }\n }\n }\n": typeof types.ProjectAutomationPageDocument,
"\n query ProjectAutomationPagePaginatedRuns(\n $projectId: String!\n $automationId: String!\n $cursor: String = null\n ) {\n project(id: $projectId) {\n id\n automation(id: $automationId) {\n id\n runs(cursor: $cursor, limit: 10) {\n totalCount\n cursor\n items {\n id\n ...AutomationRunDetails\n }\n }\n }\n }\n }\n": typeof types.ProjectAutomationPagePaginatedRunsDocument,
@@ -420,7 +420,7 @@ type Documents = {
"\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 canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\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_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\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 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,
@@ -687,7 +687,7 @@ const documents: Documents = {
"\n query ProjectModelVersions(\n $projectId: String!\n $modelId: String!\n $versionsCursor: String\n ) {\n project(id: $projectId) {\n id\n ...ProjectModelPageVersionsPagination\n }\n }\n": types.ProjectModelVersionsDocument,
"\n query ProjectModelsPage($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectModelsPageHeader_Project\n ...ProjectModelsPageResults_Project\n }\n }\n": types.ProjectModelsPageDocument,
"\n query ProjectDiscussionsPage($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectDiscussionsPageHeader_Project\n ...ProjectDiscussionsPageResults_Project\n }\n }\n": types.ProjectDiscussionsPageDocument,
"\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n": types.ProjectAutomationsTabDocument,
"\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n permissions {\n canCreateAutomation {\n ...FullPermissionCheckResult\n }\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n": types.ProjectAutomationsTabDocument,
"\n query ProjectAutomationsTabAutomationsPagination(\n $projectId: String!\n $search: String = null\n $cursor: String = null\n ) {\n project(id: $projectId) {\n id\n automations(filter: $search, cursor: $cursor, limit: 5) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n }\n }\n }\n": types.ProjectAutomationsTabAutomationsPaginationDocument,
"\n query ProjectAutomationPage($projectId: String!, $automationId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageAutomationPage_Project\n automation(id: $automationId) {\n id\n ...ProjectPageAutomationPage_Automation\n }\n }\n }\n": types.ProjectAutomationPageDocument,
"\n query ProjectAutomationPagePaginatedRuns(\n $projectId: String!\n $automationId: String!\n $cursor: String = null\n ) {\n project(id: $projectId) {\n id\n automation(id: $automationId) {\n id\n runs(cursor: $cursor, limit: 10) {\n totalCount\n cursor\n items {\n id\n ...AutomationRunDetails\n }\n }\n }\n }\n }\n": types.ProjectAutomationPagePaginatedRunsDocument,
@@ -841,7 +841,7 @@ const documents: Documents = {
"\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 canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\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_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\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 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,
@@ -1881,7 +1881,7 @@ export function graphql(source: "\n query ProjectDiscussionsPage($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 ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n"): (typeof documents)["\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n"];
export function graphql(source: "\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n permissions {\n canCreateAutomation {\n ...FullPermissionCheckResult\n }\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n"): (typeof documents)["\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n permissions {\n canCreateAutomation {\n ...FullPermissionCheckResult\n }\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2497,7 +2497,7 @@ export function graphql(source: "\n fragment ProjectPageProject on Project {\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 ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n"): (typeof documents)["\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n"];
export function graphql(source: "\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n"): (typeof documents)["\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\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
@@ -240,6 +240,11 @@ export const projectAutomationsTabQuery = graphql(`
}
...AutomateFunctionCreateDialog_Workspace
}
permissions {
canCreateAutomation {
...FullPermissionCheckResult
}
}
...FormSelectProjects_Project
}
...AutomateFunctionsPageHeader_Query
@@ -30,7 +30,6 @@
<div v-else />
</template>
<script setup lang="ts">
import { Roles } from '@speckle/shared'
import { useQuery } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import { projectAutomationPageQuery } from '~/lib/projects/graphql/queries'
@@ -38,6 +37,11 @@ import { projectAutomationPageQuery } from '~/lib/projects/graphql/queries'
graphql(`
fragment ProjectPageAutomationPage_Automation on Automation {
id
permissions {
canUpdate {
...FullPermissionCheckResult
}
}
...ProjectPageAutomationHeader_Automation
...ProjectPageAutomationFunctions_Automation
...ProjectPageAutomationRuns_Automation
@@ -71,7 +75,6 @@ const automation = computed(() => result.value?.project.automation || null)
const project = computed(() => result.value?.project)
const workspaceId = computed(() => project.value?.workspaceId ?? undefined)
const isEditable = computed(() => {
const allowedRoles: string[] = [Roles.Stream.Owner]
return allowedRoles.includes(result.value?.project.role ?? '')
return result?.value?.project?.automation?.permissions?.canUpdate.authorized ?? false
})
</script>
@@ -338,11 +338,10 @@ type AutomateMutations {
extend type Project {
automations(filter: String, cursor: String, limit: Int): AutomationCollection!
@hasStreamRole(role: STREAM_REVIEWER)
"""
Get a single automation by id. Error will be thrown if automation is not found or inaccessible.
"""
automation(id: String!): Automation! @hasStreamRole(role: STREAM_REVIEWER)
automation(id: String!): Automation!
}
input AutomateAuthCodePayloadTest {
@@ -0,0 +1,12 @@
extend type Automation {
permissions: AutomationPermissionChecks!
}
type AutomationPermissionChecks {
canRead: PermissionCheckResult!
canUpdate: PermissionCheckResult!
}
extend type ProjectPermissionChecks {
canCreateAutomation: PermissionCheckResult!
}
@@ -13,7 +13,7 @@ enum ProjectVisibility {
}
"""
Visbility without the "discoverable" option
Visibility without the "discoverable" option
"""
enum SimpleProjectVisibility {
PRIVATE
+1
View File
@@ -47,6 +47,7 @@ generates:
AutomateFunction: '@/modules/automate/helpers/graphTypes#AutomateFunctionGraphQLReturn'
AutomateFunctionRelease: '@/modules/automate/helpers/graphTypes#AutomateFunctionReleaseGraphQLReturn'
Automation: '@/modules/automate/helpers/graphTypes#AutomationGraphQLReturn'
AutomationPermissionChecks: '@/modules/automate/helpers/graphTypes#AutomationPermissionChecksGraphQLReturn'
AutomationRevision: '@/modules/automate/helpers/graphTypes#AutomationRevisionGraphQLReturn'
AutomationRevisionFunction: '@/modules/automate/helpers/graphTypes#AutomationRevisionFunctionGraphQLReturn'
AutomateRun: '@/modules/automate/helpers/graphTypes#AutomateRunGraphQLReturn'
@@ -122,6 +122,7 @@ import {
import { getEventBus } from '@/modules/shared/services/eventBus'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { BranchNotFoundError } from '@/modules/core/errors/branch'
import { mapAuthToServerError } from '@/modules/shared/helpers/errorHelper'
import { withOperationLogging } from '@/observability/domain/businessLogging'
const { FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags()
@@ -209,6 +210,14 @@ export = (FF_AUTOMATE_MODULE_ENABLED
},
Project: {
async automation(parent, args, ctx) {
const canReadAutomation = await ctx.authPolicies.project.automation.canRead({
userId: ctx.userId,
projectId: parent.id
})
if (!canReadAutomation.isOk) {
throw mapAuthToServerError(canReadAutomation.error)
}
const projectDb = await getProjectDbClient({ projectId: parent.id })
const res = ctx.loaders
@@ -224,7 +233,15 @@ export = (FF_AUTOMATE_MODULE_ENABLED
return res
},
async automations(parent, args) {
async automations(parent, args, ctx) {
const canReadAutomation = await ctx.authPolicies.project.automation.canRead({
userId: ctx.userId,
projectId: parent.id
})
if (!canReadAutomation.isOk) {
throw mapAuthToServerError(canReadAutomation.error)
}
const projectDb = await getProjectDbClient({ projectId: parent.id })
const retrievalArgs: GetProjectAutomationsParams = {
@@ -607,6 +624,14 @@ export = (FF_AUTOMATE_MODULE_ENABLED
},
ProjectAutomationMutations: {
async create(parent, { input }, ctx) {
const canCreate = await ctx.authPolicies.project.automation.canCreate({
userId: ctx.userId,
projectId: parent.projectId
})
if (!canCreate.isOk) {
throw mapAuthToServerError(canCreate.error)
}
const projectId = parent.projectId
const logger = ctx.log.child({
@@ -621,7 +646,6 @@ export = (FF_AUTOMATE_MODULE_ENABLED
automateCreateAutomation: clientCreateAutomation,
storeAutomation: storeAutomationFactory({ db: projectDb }),
storeAutomationToken: storeAutomationTokenFactory({ db: projectDb }),
validateStreamAccess,
eventEmit: getEventBus().emit
})
@@ -643,6 +667,14 @@ export = (FF_AUTOMATE_MODULE_ENABLED
return automation
},
async update(parent, { input }, ctx) {
const canUpdate = await ctx.authPolicies.project.automation.canUpdate({
userId: ctx.userId,
projectId: parent.projectId
})
if (!canUpdate.isOk) {
throw mapAuthToServerError(canUpdate.error)
}
const projectId = parent.projectId
const automationId = input.id
@@ -657,7 +689,6 @@ export = (FF_AUTOMATE_MODULE_ENABLED
const update = validateAndUpdateAutomationFactory({
getAutomation: getAutomationFactory({ db: projectDb }),
updateAutomation: updateAutomationFactory({ db: projectDb }),
validateStreamAccess,
eventEmit: getEventBus().emit
})
@@ -0,0 +1,35 @@
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { Authz } from '@speckle/shared'
export default {
Automation: {
permissions: (parent) => ({ projectId: parent.projectId })
},
AutomationPermissionChecks: {
canRead: async (parent, _args, context) => {
const canReadAutomation = await context.authPolicies.project.automation.canRead({
userId: context.userId,
projectId: parent.projectId
})
return Authz.toGraphqlResult(canReadAutomation)
},
canUpdate: async (parent, _args, context) => {
const canUpdateAutomation =
await context.authPolicies.project.automation.canUpdate({
userId: context.userId,
projectId: parent.projectId
})
return Authz.toGraphqlResult(canUpdateAutomation)
}
},
ProjectPermissionChecks: {
canCreateAutomation: async (parent, _args, context) => {
const canCreateAutomation =
await context.authPolicies.project.automation.canCreate({
userId: context.userId,
projectId: parent.projectId
})
return Authz.toGraphqlResult(canCreateAutomation)
}
}
} as Resolvers
@@ -95,3 +95,5 @@ export type ProjectAutomationsUpdatedMessageGraphQLReturn = Merge<
>
export type UserAutomateInfoGraphQLReturn = { userId: string }
export type AutomationPermissionChecksGraphQLReturn = { projectId: string }
@@ -60,7 +60,6 @@ export type CreateAutomationDeps = {
automateCreateAutomation: typeof clientCreateAutomation
storeAutomation: StoreAutomation
storeAutomationToken: StoreAutomationToken
validateStreamAccess: ValidateStreamAccess
eventEmit: EventBusEmit
}
@@ -75,27 +74,18 @@ export const createAutomationFactory =
const {
input: { name, enabled },
projectId,
userId,
userResourceAccessRules
userId
} = params
const {
createAuthCode,
automateCreateAutomation,
storeAutomation,
storeAutomationToken,
validateStreamAccess,
eventEmit
} = deps
validateAutomationName(name)
await validateStreamAccess(
userId,
projectId,
Roles.Stream.Owner,
userResourceAccessRules
)
const authCode = await createAuthCode({
userId,
action: AuthCodePayloadAction.CreateAutomation
@@ -253,7 +243,6 @@ export const createTestAutomationFactory =
export type ValidateAndUpdateAutomationDeps = {
getAutomation: GetAutomation
updateAutomation: UpdateAutomation
validateStreamAccess: ValidateStreamAccess
eventEmit: EventBusEmit
}
@@ -268,8 +257,8 @@ export const validateAndUpdateAutomationFactory =
*/
projectId?: string
}) => {
const { getAutomation, updateAutomation, validateStreamAccess, eventEmit } = deps
const { input, userId, userResourceAccessRules, projectId } = params
const { getAutomation, updateAutomation, eventEmit } = deps
const { input, projectId } = params
const existingAutomation = await getAutomation({
automationId: input.id,
@@ -279,13 +268,6 @@ export const validateAndUpdateAutomationFactory =
throw new AutomationUpdateError('Automation not found')
}
await validateStreamAccess(
userId,
existingAutomation.projectId,
Roles.Stream.Owner,
userResourceAccessRules
)
// Filter out empty (null) values from input
const updates = removeNullOrUndefinedKeys(input)
@@ -75,7 +75,6 @@ const buildAutomationUpdate = () => {
const update = validateAndUpdateAutomationFactory({
getAutomation,
updateAutomation: updateDbAutomation,
validateStreamAccess,
eventEmit: getEventBus().emit
})
@@ -136,38 +135,6 @@ const buildAutomationUpdate = () => {
})
})
it('fails if refering to a project that doesnt exist', async () => {
const create = buildAutomationCreate()
const e = await expectToThrow(
async () =>
await create({
input: { name: 'Automation', enabled: true },
projectId: 'non-existent',
userId: me.id
})
)
expect(e)
.to.have.property('message')
.match(/^User does not have required access to stream/)
})
it('fails if user does not have access to the project', async () => {
const create = buildAutomationCreate()
const e = await expectToThrow(
async () =>
await create({
input: { name: 'Automation', enabled: true },
projectId: myStream.id,
userId: otherGuy.id
})
)
expect(e)
.to.have.property('message')
.match(/^User does not have required access to stream/)
})
it('creates an automation', async () => {
let eventFired = false
const name = 'My Super Automation #1'
@@ -222,22 +189,6 @@ const buildAutomationUpdate = () => {
expect(e).to.have.property('message', 'Automation not found')
})
it('fails if refering to an automation in a project owned by someone else', async () => {
const update = buildAutomationUpdate()
const e = await expectToThrow(
async () =>
await update({
input: { id: createdAutomation.automation.id, enabled: false },
userId: otherGuy.id,
projectId: myStream.id
})
)
expect(e)
.to.have.property('message')
.match(/^User does not have required access to stream/)
})
it('fails if automation is mismatched with specified project id', async () => {
const update = buildAutomationUpdate()
@@ -4,7 +4,7 @@ import { StreamAccessRequestGraphQLReturn, ProjectAccessRequestGraphQLReturn } f
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';
import { AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationPermissionChecksGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes';
import { WorkspaceGraphQLReturn, WorkspaceSsoGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceJoinRequestMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, WorkspaceJoinRequestGraphQLReturn, LimitedWorkspaceJoinRequestGraphQLReturn, ProjectMoveToWorkspaceDryRunGraphQLReturn, ProjectRoleGraphQLReturn, WorkspacePermissionChecksGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes';
import { WorkspacePlanGraphQLReturn, WorkspacePlanUsageGraphQLReturn, PriceGraphQLReturn } from '@/modules/gatekeeperCore/helpers/graphTypes';
import { WorkspaceBillingMutationsGraphQLReturn, WorkspaceSubscriptionSeatsGraphQLReturn, WorkspaceSubscriptionGraphQLReturn } from '@/modules/gatekeeper/helpers/graphTypes';
@@ -454,6 +454,7 @@ export type Automation = {
id: Scalars['ID']['output'];
isTestAutomation: Scalars['Boolean']['output'];
name: Scalars['String']['output'];
permissions: AutomationPermissionChecks;
runs: AutomateRunCollection;
updatedAt: Scalars['DateTime']['output'];
};
@@ -471,6 +472,12 @@ export type AutomationCollection = {
totalCount: Scalars['Int']['output'];
};
export type AutomationPermissionChecks = {
__typename?: 'AutomationPermissionChecks';
canRead: PermissionCheckResult;
canUpdate: PermissionCheckResult;
};
export type AutomationRevision = {
__typename?: 'AutomationRevision';
functions: Array<AutomationRevisionFunction>;
@@ -2589,6 +2596,7 @@ export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVers
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canBroadcastActivity: PermissionCheckResult;
canCreateAutomation: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canDelete: PermissionCheckResult;
@@ -3244,7 +3252,7 @@ export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input'];
};
/** Visbility without the "discoverable" option */
/** Visibility without the "discoverable" option */
export const SimpleProjectVisibility = {
Private: 'PRIVATE',
Unlisted: 'UNLISTED'
@@ -5232,6 +5240,7 @@ export type ResolversTypes = {
AutomateRunTriggerType: AutomateRunTriggerType;
Automation: ResolverTypeWrapper<AutomationGraphQLReturn>;
AutomationCollection: ResolverTypeWrapper<Omit<AutomationCollection, 'items'> & { items: Array<ResolversTypes['Automation']> }>;
AutomationPermissionChecks: ResolverTypeWrapper<AutomationPermissionChecksGraphQLReturn>;
AutomationRevision: ResolverTypeWrapper<AutomationRevisionGraphQLReturn>;
AutomationRevisionCreateFunctionInput: AutomationRevisionCreateFunctionInput;
AutomationRevisionFunction: ResolverTypeWrapper<AutomationRevisionFunctionGraphQLReturn>;
@@ -5568,6 +5577,7 @@ export type ResolversParentTypes = {
AutomateRunCollection: Omit<AutomateRunCollection, 'items'> & { items: Array<ResolversParentTypes['AutomateRun']> };
Automation: AutomationGraphQLReturn;
AutomationCollection: Omit<AutomationCollection, 'items'> & { items: Array<ResolversParentTypes['Automation']> };
AutomationPermissionChecks: AutomationPermissionChecksGraphQLReturn;
AutomationRevision: AutomationRevisionGraphQLReturn;
AutomationRevisionCreateFunctionInput: AutomationRevisionCreateFunctionInput;
AutomationRevisionFunction: AutomationRevisionFunctionGraphQLReturn;
@@ -6077,6 +6087,7 @@ export type AutomationResolvers<ContextType = GraphQLContext, ParentType extends
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
isTestAutomation?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
permissions?: Resolver<ResolversTypes['AutomationPermissionChecks'], ParentType, ContextType>;
runs?: Resolver<ResolversTypes['AutomateRunCollection'], ParentType, ContextType, Partial<AutomationRunsArgs>>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@@ -6089,6 +6100,12 @@ export type AutomationCollectionResolvers<ContextType = GraphQLContext, ParentTy
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AutomationPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomationPermissionChecks'] = ResolversParentTypes['AutomationPermissionChecks']> = {
canRead?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canUpdate?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AutomationRevisionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomationRevision'] = ResolversParentTypes['AutomationRevision']> = {
functions?: Resolver<Array<ResolversTypes['AutomationRevisionFunction']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
@@ -6768,6 +6785,7 @@ export type ProjectPendingVersionsUpdatedMessageResolvers<ContextType = GraphQLC
export type ProjectPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ProjectPermissionChecks'] = ResolversParentTypes['ProjectPermissionChecks']> = {
canBroadcastActivity?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateAutomation?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateComment?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateModel?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canDelete?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
@@ -7654,6 +7672,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
AutomateRunCollection?: AutomateRunCollectionResolvers<ContextType>;
Automation?: AutomationResolvers<ContextType>;
AutomationCollection?: AutomationCollectionResolvers<ContextType>;
AutomationPermissionChecks?: AutomationPermissionChecksResolvers<ContextType>;
AutomationRevision?: AutomationRevisionResolvers<ContextType>;
AutomationRevisionFunction?: AutomationRevisionFunctionResolvers<ContextType>;
AutomationRevisionTriggerDefinition?: AutomationRevisionTriggerDefinitionResolvers<ContextType>;
@@ -434,6 +434,7 @@ export type Automation = {
id: Scalars['ID']['output'];
isTestAutomation: Scalars['Boolean']['output'];
name: Scalars['String']['output'];
permissions: AutomationPermissionChecks;
runs: AutomateRunCollection;
updatedAt: Scalars['DateTime']['output'];
};
@@ -451,6 +452,12 @@ export type AutomationCollection = {
totalCount: Scalars['Int']['output'];
};
export type AutomationPermissionChecks = {
__typename?: 'AutomationPermissionChecks';
canRead: PermissionCheckResult;
canUpdate: PermissionCheckResult;
};
export type AutomationRevision = {
__typename?: 'AutomationRevision';
functions: Array<AutomationRevisionFunction>;
@@ -2569,6 +2576,7 @@ export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVers
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canBroadcastActivity: PermissionCheckResult;
canCreateAutomation: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canDelete: PermissionCheckResult;
@@ -3224,7 +3232,7 @@ export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input'];
};
/** Visbility without the "discoverable" option */
/** Visibility without the "discoverable" option */
export const SimpleProjectVisibility = {
Private: 'PRIVATE',
Unlisted: 'UNLISTED'
@@ -435,6 +435,7 @@ export type Automation = {
id: Scalars['ID']['output'];
isTestAutomation: Scalars['Boolean']['output'];
name: Scalars['String']['output'];
permissions: AutomationPermissionChecks;
runs: AutomateRunCollection;
updatedAt: Scalars['DateTime']['output'];
};
@@ -452,6 +453,12 @@ export type AutomationCollection = {
totalCount: Scalars['Int']['output'];
};
export type AutomationPermissionChecks = {
__typename?: 'AutomationPermissionChecks';
canRead: PermissionCheckResult;
canUpdate: PermissionCheckResult;
};
export type AutomationRevision = {
__typename?: 'AutomationRevision';
functions: Array<AutomationRevisionFunction>;
@@ -2570,6 +2577,7 @@ export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVers
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canBroadcastActivity: PermissionCheckResult;
canCreateAutomation: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canDelete: PermissionCheckResult;
@@ -3225,7 +3233,7 @@ export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input'];
};
/** Visbility without the "discoverable" option */
/** Visibility without the "discoverable" option */
export const SimpleProjectVisibility = {
Private: 'PRIVATE',
Unlisted: 'UNLISTED'
@@ -80,7 +80,6 @@ export const buildAutomationCreate = (
})),
storeAutomation: storeAutomationFactory({ db: dbClient }),
storeAutomationToken: storeAutomationTokenFactory({ db: dbClient }),
validateStreamAccess,
eventEmit: getEventBus().emit
})
@@ -21,11 +21,19 @@ import { canCreateProjectVersionPolicy } from './project/version/canCreate.js'
import { canUpdateProjectVersionPolicy } from './project/version/canUpdate.js'
import { canReceiveProjectVersionPolicy } from './project/version/canReceive.js'
import { canRequestProjectVersionRenderPolicy } from './project/version/canRequestRender.js'
import { canCreateAutomationPolicy } from './project/automation/canCreate.js'
import { canUpdateAutomationPolicy } from './project/automation/canUpdate.js'
import { canReadAutomationPolicy } from './project/automation/canRead.js'
import { canReceiveWorkspaceProjectsUpdatedMessagePolicy } from './workspace/canReceiveProjectsUpdatedMessage.js'
import { canDeleteProjectPolicy } from './project/canDelete.js'
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
project: {
automation: {
canCreate: canCreateAutomationPolicy(loaders),
canRead: canReadAutomationPolicy(loaders),
canUpdate: canUpdateAutomationPolicy(loaders)
},
model: {
canCreate: canCreateModelPolicy(loaders),
canUpdate: canUpdateModelPolicy(loaders),
@@ -0,0 +1,255 @@
import { describe, expect, it } from 'vitest'
import { Roles } from '../../../../core/constants.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { canCreateAutomationPolicy } from './canCreate.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
const buildCanCreatePolicy = (
overrides?: Partial<Parameters<typeof canCreateAutomationPolicy>[0]>
) =>
canCreateAutomationPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getProjectRole: async () => Roles.Stream.Owner,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
describe('canCreateAutomation', () => {
it('returns error if user is not logged in', async () => {
const canCreateAutomation = buildCanCreatePolicy()
const result = await canCreateAutomation({
userId: undefined,
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('returns error if user is not found', async () => {
const canCreateAutomation = buildCanCreatePolicy({
getServerRole: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('returns error if user is a server guest', async () => {
const canCreateAutomation = buildCanCreatePolicy({
getServerRole: async () => Roles.Server.Guest
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNotEnoughPermissionsError.code
})
})
it('returns error project not found', async () => {
const canCreateAutomation = buildCanCreatePolicy({
getProject: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('returns error if no role at all', async () => {
const canCreateAutomation = buildCanCreatePolicy({
getProjectRole: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('returns error if not owner', async () => {
const canCreateAutomation = buildCanCreatePolicy({
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotEnoughPermissionsError.code
})
})
it('returns ok if permissible', async () => {
const canCreateAutomation = buildCanCreatePolicy()
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
describe('with workspace project', () => {
const overrides = {
getProject: async () => ({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => {
const validUntil = new Date()
validUntil.setDate(validUntil.getDate() + 7)
return {
userId: 'user-id',
providerId: 'provider-id',
validUntil
}
}
}
it('returns ok if permissible', async () => {
const canCreateAutomation = buildCanCreatePolicy(overrides)
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns ok with implicit owner role', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceRole: async () => Roles.Workspace.Admin,
getProjectRole: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if no workspace role, even w/ valid project role', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceRole: async () => null,
getProjectRole: async () => Roles.Stream.Owner
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceNoAccessError.code
})
})
it('returns error if invalid workspace and project role', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceRole: async () => Roles.Workspace.Member,
getProjectRole: async () => Roles.Stream.Contributor
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotEnoughPermissionsError.code
})
})
it('returns ok if no sso configured', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if no sso session', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceSsoSession: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('returns error if sso expired', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(new Date().getTime() - 1000)
})
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,63 @@
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 { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../../fragments/projects.js'
import { Loaders } from '../../../domain/loaders.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceNotEnoughPermissionsError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
type PolicyLoaderKeys =
| typeof Loaders.getProject
| typeof Loaders.getServerRole
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole
type PolicyArgs = ProjectContext & MaybeUserContext
type PolicyErrors = InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ServerNotEnoughPermissionsError
| typeof WorkspaceSsoSessionNoAccessError
| typeof WorkspaceNotEnoughPermissionsError
| typeof ProjectNotEnoughPermissionsError
>
export const canCreateAutomationPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, projectId }) => {
// Project owners may create automations
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId,
role: Roles.Stream.Owner
})
if (ensuredWriteAccess.isErr) {
return err(ensuredWriteAccess.error)
}
return ok()
}
@@ -0,0 +1,240 @@
import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { canReadAutomationPolicy } from './canRead.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getProjectFake, getWorkspaceFake } from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
const buildCanReadAutomationPolicy = (
overrides?: OverridesOf<typeof canReadAutomationPolicy>
) =>
canReadAutomationPolicy({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isPublic: false,
isDiscoverable: false
}),
getProjectRole: async () => Roles.Stream.Reviewer,
getAdminOverrideEnabled: async () => false,
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => Roles.Server.Guest,
getWorkspaceRole: async () => null,
getWorkspace: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
describe('canReadAutomationPolicy', () => {
it('should allow for reviewers+', async () => {
const canReadAutomation = buildCanReadAutomationPolicy()
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('should allow for admin w/ override', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
getAdminOverrideEnabled: async () => true,
getServerRole: async () => Roles.Server.Admin,
getProjectRole: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails without user', async () => {
const canReadAutomation = buildCanReadAutomationPolicy()
const result = await canReadAutomation({
userId: undefined,
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
getServerRole: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if user has no project role', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
getProjectRole: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails if project not found', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
getProject: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
describe('with workspace project', () => {
const overrides = {
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isPublic: false,
isDiscoverable: false
}),
getWorkspace: getWorkspaceFake({
id: 'workspace-id'
}),
getProjectRole: async () => null,
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(Date.now() + 1000 * 60 * 60)
}),
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
})
}
it('succeeds w/ implicit project role', async () => {
const canReadAutomation = buildCanReadAutomationPolicy(overrides)
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o workspace role, even w/ project role', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
...overrides,
getProjectRole: async () => Roles.Stream.Reviewer,
getWorkspaceRole: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceNoAccessError.code
})
})
it('fails w/o workspace role', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
...overrides,
getWorkspaceRole: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds w/o sso, if not needed', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
...overrides,
getWorkspaceSsoSession: async () => null,
getWorkspaceSsoProvider: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o sso', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
...overrides,
getWorkspaceSsoSession: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('fails if sso session expired', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
...overrides,
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(Date.now() - 1000 * 60 * 60)
})
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,58 @@
import { err, ok } from 'true-myth/result'
import { AuthPolicy } from '../../../domain/policies.js'
import { MaybeUserContext, ProjectContext } from '../../../domain/context.js'
import { ensureImplicitProjectMemberWithReadAccessFragment } from '../../../fragments/projects.js'
import {} from '../../../fragments/server.js'
import { Loaders } from '../../../domain/loaders.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceNotEnoughPermissionsError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { Roles } from '../../../../core/constants.js'
export const canReadAutomationPolicy: 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 ServerNotEnoughPermissionsError
| typeof ProjectNoAccessError
| typeof WorkspaceNoAccessError
| typeof WorkspaceSsoSessionNoAccessError
| typeof WorkspaceNotEnoughPermissionsError
| typeof ProjectNotEnoughPermissionsError
>
> =
(loaders) =>
async ({ userId, projectId }) => {
// Project reviewers may read automations
const hasReadAccess = await ensureImplicitProjectMemberWithReadAccessFragment(
loaders
)({
userId,
projectId,
role: Roles.Stream.Reviewer
})
if (hasReadAccess.isErr) {
return err(hasReadAccess.error)
}
return ok()
}
@@ -0,0 +1,255 @@
import { describe, expect, it } from 'vitest'
import { Roles } from '../../../../core/constants.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { canUpdateAutomationPolicy } from './canUpdate.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
const buildCanUpdatePolicy = (
overrides?: Partial<Parameters<typeof canUpdateAutomationPolicy>[0]>
) =>
canUpdateAutomationPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getProjectRole: async () => Roles.Stream.Owner,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
describe('canUpdateAutomation', () => {
it('returns error if user is not logged in', async () => {
const canUpdateAutomation = buildCanUpdatePolicy()
const result = await canUpdateAutomation({
userId: undefined,
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('returns error if user is not found', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
getServerRole: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('returns error if user is a server guest', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
getServerRole: async () => Roles.Server.Guest
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNotEnoughPermissionsError.code
})
})
it('returns error project not found', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
getProject: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('returns error if no role at all', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
getProjectRole: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('returns error if not owner', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotEnoughPermissionsError.code
})
})
it('returns ok if permissible', async () => {
const canUpdateAutomation = buildCanUpdatePolicy()
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
describe('with workspace project', () => {
const overrides = {
getProject: async () => ({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => {
const validUntil = new Date()
validUntil.setDate(validUntil.getDate() + 7)
return {
userId: 'user-id',
providerId: 'provider-id',
validUntil
}
}
}
it('returns ok if permissible', async () => {
const canUpdateAutomation = buildCanUpdatePolicy(overrides)
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns ok with implicit owner role', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceRole: async () => Roles.Workspace.Admin,
getProjectRole: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if no workspace role, even w/ valid project role', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceRole: async () => null,
getProjectRole: async () => Roles.Stream.Owner
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceNoAccessError.code
})
})
it('returns error if invalid workspace and project role', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceRole: async () => Roles.Workspace.Member,
getProjectRole: async () => Roles.Stream.Contributor
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotEnoughPermissionsError.code
})
})
it('returns ok if no sso configured', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if no sso session', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceSsoSession: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('returns error if sso expired', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(new Date().getTime() - 1000)
})
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,63 @@
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 { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../../fragments/projects.js'
import { Loaders } from '../../../domain/loaders.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceNotEnoughPermissionsError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
type PolicyLoaderKeys =
| typeof Loaders.getProject
| typeof Loaders.getServerRole
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole
type PolicyArgs = ProjectContext & MaybeUserContext
type PolicyErrors = InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ServerNotEnoughPermissionsError
| typeof WorkspaceSsoSessionNoAccessError
| typeof WorkspaceNotEnoughPermissionsError
| typeof ProjectNotEnoughPermissionsError
>
export const canUpdateAutomationPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, projectId }) => {
// Project owners may update automations
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId,
role: Roles.Stream.Owner
})
if (ensuredWriteAccess.isErr) {
return err(ensuredWriteAccess.error)
}
return ok()
}