gergo/web 2888 workspace project cancreate (#4294)

* WIP can create project

* WIP can create project more work

* complete body, stencil tests

* feat(shared): move workspace plan types into shared

* test progress wip

* feat(shared): add more logic to canCreateWorkspaceProject

* a few more tests, as a treat

* chore(authz): round out tests

* fixed loaders, new GQL checks, dataLoaders in auth loaders

* fix(authz): get workspace limits loader

* chore(authz): update loaders

* frontend fixed up to snuff

* fix(authz): fix workspace plans for tests

* fix(authz): classic

* fix(authz): 0 counts

---------

Co-authored-by: Chuck Driesler <chuck@speckle.systems>
Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com>
This commit is contained in:
Gergő Jedlicska
2025-04-01 17:38:20 +02:00
committed by GitHub
parent 1040990234
commit f501cc4ad5
82 changed files with 1795 additions and 1470 deletions
@@ -1,4 +0,0 @@
export const useAuthPolicies = () => {
const nuxt = useNuxtApp()
return nuxt.$authPolicies
}
@@ -0,0 +1,10 @@
import { graphql } from '~/lib/common/generated/gql'
export const permissionCheckResultFragment = graphql(`
fragment FullPermissionCheckResult on PermissionCheckResult {
authorized
code
message
payload
}
`)
@@ -1,9 +0,0 @@
import type { AuthCheckContextLoaders } from '@speckle/shared/authz'
import type { AuthLoaderFactory } from '~/lib/auth/helpers/authPolicies'
export const getEnvFactory: AuthLoaderFactory<AuthCheckContextLoaders['getEnv']> = (
deps
) => {
const { public: publicRuntimeConfig } = deps.nuxtApp.$config
return async () => publicRuntimeConfig
}
@@ -1,79 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { NuxtApp } from '#app'
import type {
DocumentNode,
OperationVariables,
QueryOptions
} from '@apollo/client/core'
import type { Authz } from '@speckle/shared'
import type { AuthLoaderDependencies } from '~/lib/auth/helpers/authPolicies'
import { getEnvFactory } from '~/lib/auth/loaders/env'
import { getProjectFactory, getProjectRoleFactory } from '~/lib/auth/loaders/project'
import { getServerRoleFactory } from '~/lib/auth/loaders/server'
import {
getWorkspaceFactory,
getWorkspaceRoleFactory,
getWorkspaceSsoProviderFactory,
getWorkspaceSsoSessionFactory
} from '~/lib/auth/loaders/workspace'
export const buildAuthPolicyLoaders = (params: {
nuxtApp: NuxtApp
options?: Partial<{
/**
* Whether loaders should skip cache and fetch results from server
*/
noCache: boolean
}>
}): Authz.AllAuthCheckContextLoaders => {
const apollo = params.nuxtApp['$apollo'].default
if (!apollo) {
throw new Error('Apollo client not found')
}
const requestedQueryMap: WeakMap<DocumentNode, Set<string>> = new WeakMap()
const query: (typeof apollo)['query'] = <
T = any,
TVariables extends OperationVariables = OperationVariables
>(
options: QueryOptions<TVariables, T>
) => {
const op = options.query
const vars = JSON.stringify(toValue(options.variables))
// If noCache - we want fetchPolicy: network-only ONLY on the first load of a specific
// gql query. network-only on subsequent loads within the same policy will be too heavy
if (params.options?.noCache && !options.fetchPolicy) {
if (!requestedQueryMap.has(op)) {
requestedQueryMap.set(op, new Set())
}
const queryMap = requestedQueryMap.get(op)!
if (!queryMap.has(vars)) {
queryMap.add(vars)
options.fetchPolicy = 'network-only'
} else {
options.fetchPolicy = 'cache-first'
}
}
return apollo.query(options)
}
const deps: AuthLoaderDependencies = {
nuxtApp: params.nuxtApp,
query
}
return {
getEnv: getEnvFactory(deps),
getProject: getProjectFactory(deps),
getProjectRole: getProjectRoleFactory(deps),
getServerRole: getServerRoleFactory(deps),
getWorkspace: getWorkspaceFactory(deps),
getWorkspaceRole: getWorkspaceRoleFactory(deps),
getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory(deps),
getWorkspaceSsoSession: getWorkspaceSsoSessionFactory(deps)
}
}
@@ -1,128 +0,0 @@
import {
ProjectNoAccessError,
ProjectNotFoundError,
ProjectRoleNotFoundError,
WorkspaceSsoSessionNoAccessError,
type AuthCheckContextLoaders
} from '@speckle/shared/authz'
import { err, ok } from 'true-myth/result'
import { graphql } from '~/lib/common/generated/gql'
import { SimpleProjectVisibility } from '~/lib/common/generated/gql/graphql'
import { hasErrorWith } from '~/lib/common/helpers/graphql'
import { WorkspaceSsoErrorCodes } from '~/lib/workspaces/helpers/types'
import type { StreamRoles } from '@speckle/shared'
import { ActiveUserId, type AuthLoaderFactory } from '~/lib/auth/helpers/authPolicies'
const ProjectErrorCodes = <const>{
NotFound: 'STREAM_NOT_FOUND',
Forbidden: 'FORBIDDEN',
SsoSessionError: WorkspaceSsoErrorCodes.SESSION_MISSING_OR_EXPIRED
}
// Re-using same query for multiple checks for optimal performance
const authzProjectMetadataQuery = graphql(`
query AuthzProjectMetadata($id: String!) {
project(id: $id) {
id
...AuthzGetProject_Project
...AuthzGetProjectRole_Project
}
}
`)
graphql(`
fragment AuthzGetProject_Project on Project {
id
visibility
workspaceId
}
`)
export const getProjectFactory: AuthLoaderFactory<
AuthCheckContextLoaders['getProject']
> = (deps) => {
return async ({ projectId }) => {
const { data, errors } = await deps
.query({
query: authzProjectMetadataQuery,
variables: { id: projectId }
})
.catch(convertThrowIntoFetchResult)
const isSsoSessionError = hasErrorWith({
errors,
code: ProjectErrorCodes.SsoSessionError
})
if (isSsoSessionError)
return err(
new WorkspaceSsoSessionNoAccessError({
payload: {
workspaceSlug: isSsoSessionError.message
}
})
)
const isNotFound = hasErrorWith({ errors, code: ProjectErrorCodes.NotFound })
if (isNotFound) return err(new ProjectNotFoundError())
const isForbidden = hasErrorWith({
errors,
code: ProjectErrorCodes.Forbidden
})
if (isForbidden) return err(new ProjectNoAccessError())
if (data?.project.id)
return ok({
id: data.project.id,
isDiscoverable: false,
isPublic: data.project.visibility === SimpleProjectVisibility.Unlisted,
workspaceId: data.project.workspaceId || null
})
throw new Error("Couldn't retrieve project due to unexpected error")
}
}
graphql(`
fragment AuthzGetProjectRole_Project on Project {
id
role
}
`)
export const getProjectRoleFactory: AuthLoaderFactory<
AuthCheckContextLoaders['getProjectRole']
> = (deps) => {
const { userId: activeUserId } = useActiveUser()
return async ({ projectId, userId }) => {
if (userId !== activeUserId.value && userId !== ActiveUserId) {
throw new Error('Checking project role for a different user is not supported')
}
const { data, errors } = await deps
.query({
query: authzProjectMetadataQuery,
variables: { id: projectId }
})
.catch(convertThrowIntoFetchResult)
const hasExpectedNotFoundErrors = hasErrorWith({
errors,
codes: [
ProjectErrorCodes.NotFound,
ProjectErrorCodes.Forbidden,
ProjectErrorCodes.SsoSessionError
]
})
if (hasExpectedNotFoundErrors) return err(new ProjectRoleNotFoundError())
if (data?.project.id) {
return data.project.role
? ok(data.project.role as StreamRoles)
: err(new ProjectRoleNotFoundError())
}
throw new Error("Couldn't retrieve project role due to unexpected error")
}
}
@@ -1,52 +0,0 @@
import {
ServerRoleNotFoundError,
type AuthCheckContextLoaders
} from '@speckle/shared/authz'
import type { ServerRoles } from '@speckle/shared'
import { err, ok } from 'true-myth/result'
import { graphql } from '~/lib/common/generated/gql'
import { ActiveUserId, type AuthLoaderFactory } from '~/lib/auth/helpers/authPolicies'
const authzServerMetadataQuery = graphql(`
query AuthzServerMetadata {
activeUser {
id
...AuthzGetServerRole_User
}
}
`)
graphql(`
fragment AuthzGetServerRole_User on User {
id
role
}
`)
export const getServerRoleFactory: AuthLoaderFactory<
AuthCheckContextLoaders['getServerRole']
> = (deps) => {
const { userId: activeUserId } = useActiveUser()
return async ({ userId }) => {
if (userId !== activeUserId.value && userId !== ActiveUserId) {
throw new Error('Checking server role for another user is not supported')
}
const { data, errors } = await deps
.query({
query: authzServerMetadataQuery,
// We're fine with always using the cache for this, it's very unlikely that user's role will change
// and if it does it's definitely gonna cause a problem elsewhere
fetchPolicy: 'cache-first'
})
.catch(convertThrowIntoFetchResult)
if (errors?.length) {
throw new Error('Failed to load server role')
}
return data?.activeUser?.role
? ok(data.activeUser.role as ServerRoles)
: err(new ServerRoleNotFoundError())
}
}
@@ -1,205 +0,0 @@
import type { WorkspaceRoles } from '@speckle/shared'
import {
WorkspaceNoAccessError,
WorkspaceNotFoundError,
WorkspaceRoleNotFoundError,
WorkspaceSsoProviderNotFoundError,
WorkspaceSsoSessionNoAccessError,
WorkspaceSsoSessionNotFoundError,
type AuthCheckContextLoaders
} from '@speckle/shared/authz'
import dayjs from 'dayjs'
import { err, ok } from 'true-myth/result'
import { ActiveUserId, type AuthLoaderFactory } from '~/lib/auth/helpers/authPolicies'
import { graphql } from '~/lib/common/generated/gql'
import { hasErrorWith } from '~/lib/common/helpers/graphql'
import { WorkspaceSsoErrorCodes } from '~/lib/workspaces/helpers/types'
const WorkspaceErrorCodes = <const>{
NotFound: 'WORKSPACE_NOT_FOUND_ERROR',
Forbidden: 'FORBIDDEN',
SsoSessionError: WorkspaceSsoErrorCodes.SESSION_MISSING_OR_EXPIRED
}
const authzWorkspaceMetadataQuery = graphql(`
query AuthzWorkspaceMetadata($id: String!) {
workspace(id: $id) {
id
...AuthzGetWorkspace_Workspace
...AuthzGetWorkspaceRole_Workspace
...AuthzGetWorkspaceSsoProviderSession_Workspace
}
}
`)
graphql(`
fragment AuthzGetWorkspace_Workspace on Workspace {
id
slug
}
`)
export const getWorkspaceFactory: AuthLoaderFactory<
AuthCheckContextLoaders['getWorkspace']
> = (deps) => {
return async ({ workspaceId }) => {
const { data, errors } = await deps
.query({
query: authzWorkspaceMetadataQuery,
variables: { id: workspaceId }
})
.catch(convertThrowIntoFetchResult)
const isSsoSessionError = hasErrorWith({
errors,
code: WorkspaceErrorCodes.SsoSessionError
})
if (isSsoSessionError)
return err(
new WorkspaceSsoSessionNoAccessError({
payload: {
workspaceSlug: isSsoSessionError.message
}
})
)
const isNotFound = hasErrorWith({ errors, code: WorkspaceErrorCodes.NotFound })
if (isNotFound) return err(new WorkspaceNotFoundError())
const isForbidden = hasErrorWith({
errors,
code: WorkspaceErrorCodes.Forbidden
})
if (isForbidden) return err(new WorkspaceNoAccessError())
if (data?.workspace.id) return ok(data.workspace)
throw new Error('Unexpectedly failed to load workspace')
}
}
graphql(`
fragment AuthzGetWorkspaceRole_Workspace on Workspace {
id
role
}
`)
export const getWorkspaceRoleFactory: AuthLoaderFactory<
AuthCheckContextLoaders['getWorkspaceRole']
> = (deps) => {
const { userId: activeUserId } = useActiveUser()
return async ({ workspaceId, userId }) => {
if (userId !== activeUserId.value && userId !== ActiveUserId) {
throw new Error('Checking workspace role for another user is not supported')
}
const { data, errors } = await deps
.query({
query: authzWorkspaceMetadataQuery,
variables: { id: workspaceId }
})
.catch(convertThrowIntoFetchResult)
const hasExpectedNotFoundErrors = hasErrorWith({
errors,
codes: [
WorkspaceErrorCodes.NotFound,
WorkspaceErrorCodes.Forbidden,
WorkspaceErrorCodes.SsoSessionError
]
})
if (hasExpectedNotFoundErrors) return err(new WorkspaceRoleNotFoundError())
if (data?.workspace.role) {
return ok(data.workspace.role as WorkspaceRoles)
}
throw new Error("Couldn't retrieve project role due to unexpected error")
}
}
graphql(`
fragment AuthzGetWorkspaceSsoProviderSession_Workspace on Workspace {
id
sso {
provider {
id
}
session {
validUntil
}
}
}
`)
export const getWorkspaceSsoProviderFactory: AuthLoaderFactory<
AuthCheckContextLoaders['getWorkspaceSsoProvider']
> = (deps) => {
return async ({ workspaceId }) => {
const { data, errors } = await deps
.query({
query: authzWorkspaceMetadataQuery,
variables: { id: workspaceId }
})
.catch(convertThrowIntoFetchResult)
const hasExpectedNotFoundErrors = hasErrorWith({
errors,
codes: [
WorkspaceErrorCodes.NotFound,
WorkspaceErrorCodes.Forbidden,
WorkspaceErrorCodes.SsoSessionError
]
})
if (hasExpectedNotFoundErrors) return err(new WorkspaceSsoProviderNotFoundError())
if (errors?.length) {
throw new Error("Couldn't retrieve project role due to unexpected error")
}
return data?.workspace.sso?.provider
? ok({ providerId: data.workspace.sso.provider.id })
: err(new WorkspaceSsoProviderNotFoundError())
}
}
export const getWorkspaceSsoSessionFactory: AuthLoaderFactory<
AuthCheckContextLoaders['getWorkspaceSsoSession']
> = (deps) => {
const { userId: activeUserId } = useActiveUser()
return async ({ workspaceId, userId }) => {
if (userId !== activeUserId.value && userId !== ActiveUserId) {
throw new Error('Checking workspace session for another user is not supported')
}
if (!activeUserId.value) return err(new WorkspaceSsoSessionNotFoundError())
const { data, errors } = await deps
.query({
query: authzWorkspaceMetadataQuery,
variables: { id: workspaceId }
})
.catch(convertThrowIntoFetchResult)
const hasExpectedNotFoundErrors = hasErrorWith({
errors,
codes: [
WorkspaceErrorCodes.NotFound,
WorkspaceErrorCodes.Forbidden,
WorkspaceErrorCodes.SsoSessionError
]
})
if (hasExpectedNotFoundErrors) return err(new WorkspaceSsoSessionNotFoundError())
if (errors?.length) {
throw new Error("Couldn't retrieve project role due to unexpected error")
}
return data?.workspace.sso?.session && data.workspace.sso.provider
? ok({
providerId: data.workspace.sso.provider.id,
userId: activeUserId.value,
validUntil: dayjs(data.workspace.sso.session.validUntil).toDate()
})
: err(new WorkspaceSsoSessionNotFoundError())
}
}
@@ -161,6 +161,7 @@ type Documents = {
"\n fragment WorkspaceWizardStepRegion_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": typeof types.WorkspaceWizardStepRegion_ServerInfoFragmentDoc,
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n verified\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n": typeof types.ActiveUserMainMetadataDocument,
"\n mutation CreateOnboardingProject {\n projectMutations {\n createForOnboarding {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n ": typeof types.CreateOnboardingProjectDocument,
"\n fragment FullPermissionCheckResult on PermissionCheckResult {\n authorized\n code\n message\n payload\n }\n": typeof types.FullPermissionCheckResultFragmentDoc,
"\n mutation FinishOnboarding($input: OnboardingCompletionInput) {\n activeUserMutations {\n finishOnboarding(input: $input)\n }\n }\n": typeof types.FinishOnboardingDocument,
"\n mutation RequestVerificationByEmail($email: String!) {\n requestVerificationByEmail(email: $email)\n }\n": typeof types.RequestVerificationByEmailDocument,
"\n query AuthLoginPanel {\n serverInfo {\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n }\n }\n": typeof types.AuthLoginPanelDocument,
@@ -170,15 +171,6 @@ type Documents = {
"\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n items {\n id\n slug\n }\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n": typeof types.ActiveUserWorkspaceExistenceCheckDocument,
"\n query ActiveUserActiveWorkspaceCheck {\n activeUser {\n id\n isProjectsActive\n activeWorkspace {\n id\n slug\n }\n }\n }\n": typeof types.ActiveUserActiveWorkspaceCheckDocument,
"\n query projectWorkspaceAccessCheck($projectId: String!) {\n project(id: $projectId) {\n id\n role\n workspace {\n id\n slug\n role\n }\n }\n }\n": typeof types.ProjectWorkspaceAccessCheckDocument,
"\n query AuthzProjectMetadata($id: String!) {\n project(id: $id) {\n id\n ...AuthzGetProject_Project\n ...AuthzGetProjectRole_Project\n }\n }\n": typeof types.AuthzProjectMetadataDocument,
"\n fragment AuthzGetProject_Project on Project {\n id\n visibility\n workspaceId\n }\n": typeof types.AuthzGetProject_ProjectFragmentDoc,
"\n fragment AuthzGetProjectRole_Project on Project {\n id\n role\n }\n": typeof types.AuthzGetProjectRole_ProjectFragmentDoc,
"\n query AuthzServerMetadata {\n activeUser {\n id\n ...AuthzGetServerRole_User\n }\n }\n": typeof types.AuthzServerMetadataDocument,
"\n fragment AuthzGetServerRole_User on User {\n id\n role\n }\n": typeof types.AuthzGetServerRole_UserFragmentDoc,
"\n query AuthzWorkspaceMetadata($id: String!) {\n workspace(id: $id) {\n id\n ...AuthzGetWorkspace_Workspace\n ...AuthzGetWorkspaceRole_Workspace\n ...AuthzGetWorkspaceSsoProviderSession_Workspace\n }\n }\n": typeof types.AuthzWorkspaceMetadataDocument,
"\n fragment AuthzGetWorkspace_Workspace on Workspace {\n id\n slug\n }\n": typeof types.AuthzGetWorkspace_WorkspaceFragmentDoc,
"\n fragment AuthzGetWorkspaceRole_Workspace on Workspace {\n id\n role\n }\n": typeof types.AuthzGetWorkspaceRole_WorkspaceFragmentDoc,
"\n fragment AuthzGetWorkspaceSsoProviderSession_Workspace on Workspace {\n id\n sso {\n provider {\n id\n }\n session {\n validUntil\n }\n }\n }\n": typeof types.AuthzGetWorkspaceSsoProviderSession_WorkspaceFragmentDoc,
"\n fragment FunctionRunStatusForSummary on AutomateFunctionRun {\n id\n status\n }\n": typeof types.FunctionRunStatusForSummaryFragmentDoc,
"\n fragment TriggeredAutomationsStatusSummary on TriggeredAutomationsStatus {\n id\n automationRuns {\n id\n functionRuns {\n id\n ...FunctionRunStatusForSummary\n }\n }\n }\n": typeof types.TriggeredAutomationsStatusSummaryFragmentDoc,
"\n fragment AutomationRunDetails on AutomateRun {\n id\n status\n functionRuns {\n ...FunctionRunStatusForSummary\n statusMessage\n }\n trigger {\n ... on VersionCreatedTrigger {\n version {\n id\n }\n model {\n id\n }\n }\n }\n createdAt\n updatedAt\n }\n": typeof types.AutomationRunDetailsFragmentDoc,
@@ -266,7 +258,7 @@ type Documents = {
"\n mutation TriggerAutomation($projectId: ID!, $automationId: ID!) {\n projectMutations {\n automationMutations(projectId: $projectId) {\n trigger(automationId: $automationId)\n }\n }\n }\n": typeof types.TriggerAutomationDocument,
"\n mutation CreateTestAutomation(\n $projectId: ID!\n $input: ProjectTestAutomationCreateInput!\n ) {\n projectMutations {\n automationMutations(projectId: $projectId) {\n createTestAutomation(input: $input) {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n }\n }\n }\n": typeof types.CreateTestAutomationDocument,
"\n mutation MoveProjectToWorkspace($workspaceId: String!, $projectId: String!) {\n workspaceMutations {\n projects {\n moveToWorkspace(workspaceId: $workspaceId, projectId: $projectId) {\n id\n workspace {\n id\n projects {\n items {\n id\n }\n }\n ...ProjectsMoveToWorkspaceDialog_Workspace\n ...MoveProjectsDialog_Workspace\n }\n }\n }\n }\n }\n": typeof types.MoveProjectToWorkspaceDocument,
"\n query ProjectAccessCheck($id: String!) {\n project(id: $id) {\n id\n visibility\n workspace {\n id\n slug\n }\n }\n }\n": typeof types.ProjectAccessCheckDocument,
"\n query ProjectAccessCheck($id: String!) {\n project(id: $id) {\n id\n permissions {\n canRead {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.ProjectAccessCheckDocument,
"\n query ProjectRoleCheck($id: String!) {\n project(id: $id) {\n id\n role\n }\n }\n": typeof types.ProjectRoleCheckDocument,
"\n query ProjectsDashboardQuery($filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(filter: $filter, limit: 6, cursor: $cursor) {\n ...ProjectsDashboard_UserProjectCollection\n cursor\n totalCount\n items {\n ...ProjectDashboardItem\n }\n }\n ...ProjectsHiddenProjectWarning_User\n ...ProjectsDashboardHeaderProjects_User\n }\n }\n": typeof types.ProjectsDashboardQueryDocument,
"\n query ProjectsDashboardWorkspaceQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n": typeof types.ProjectsDashboardWorkspaceQueryDocument,
@@ -583,6 +575,7 @@ const documents: Documents = {
"\n fragment WorkspaceWizardStepRegion_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": types.WorkspaceWizardStepRegion_ServerInfoFragmentDoc,
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n verified\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserMainMetadataDocument,
"\n mutation CreateOnboardingProject {\n projectMutations {\n createForOnboarding {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n ": types.CreateOnboardingProjectDocument,
"\n fragment FullPermissionCheckResult on PermissionCheckResult {\n authorized\n code\n message\n payload\n }\n": types.FullPermissionCheckResultFragmentDoc,
"\n mutation FinishOnboarding($input: OnboardingCompletionInput) {\n activeUserMutations {\n finishOnboarding(input: $input)\n }\n }\n": types.FinishOnboardingDocument,
"\n mutation RequestVerificationByEmail($email: String!) {\n requestVerificationByEmail(email: $email)\n }\n": types.RequestVerificationByEmailDocument,
"\n query AuthLoginPanel {\n serverInfo {\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n }\n }\n": types.AuthLoginPanelDocument,
@@ -592,15 +585,6 @@ const documents: Documents = {
"\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n items {\n id\n slug\n }\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserWorkspaceExistenceCheckDocument,
"\n query ActiveUserActiveWorkspaceCheck {\n activeUser {\n id\n isProjectsActive\n activeWorkspace {\n id\n slug\n }\n }\n }\n": types.ActiveUserActiveWorkspaceCheckDocument,
"\n query projectWorkspaceAccessCheck($projectId: String!) {\n project(id: $projectId) {\n id\n role\n workspace {\n id\n slug\n role\n }\n }\n }\n": types.ProjectWorkspaceAccessCheckDocument,
"\n query AuthzProjectMetadata($id: String!) {\n project(id: $id) {\n id\n ...AuthzGetProject_Project\n ...AuthzGetProjectRole_Project\n }\n }\n": types.AuthzProjectMetadataDocument,
"\n fragment AuthzGetProject_Project on Project {\n id\n visibility\n workspaceId\n }\n": types.AuthzGetProject_ProjectFragmentDoc,
"\n fragment AuthzGetProjectRole_Project on Project {\n id\n role\n }\n": types.AuthzGetProjectRole_ProjectFragmentDoc,
"\n query AuthzServerMetadata {\n activeUser {\n id\n ...AuthzGetServerRole_User\n }\n }\n": types.AuthzServerMetadataDocument,
"\n fragment AuthzGetServerRole_User on User {\n id\n role\n }\n": types.AuthzGetServerRole_UserFragmentDoc,
"\n query AuthzWorkspaceMetadata($id: String!) {\n workspace(id: $id) {\n id\n ...AuthzGetWorkspace_Workspace\n ...AuthzGetWorkspaceRole_Workspace\n ...AuthzGetWorkspaceSsoProviderSession_Workspace\n }\n }\n": types.AuthzWorkspaceMetadataDocument,
"\n fragment AuthzGetWorkspace_Workspace on Workspace {\n id\n slug\n }\n": types.AuthzGetWorkspace_WorkspaceFragmentDoc,
"\n fragment AuthzGetWorkspaceRole_Workspace on Workspace {\n id\n role\n }\n": types.AuthzGetWorkspaceRole_WorkspaceFragmentDoc,
"\n fragment AuthzGetWorkspaceSsoProviderSession_Workspace on Workspace {\n id\n sso {\n provider {\n id\n }\n session {\n validUntil\n }\n }\n }\n": types.AuthzGetWorkspaceSsoProviderSession_WorkspaceFragmentDoc,
"\n fragment FunctionRunStatusForSummary on AutomateFunctionRun {\n id\n status\n }\n": types.FunctionRunStatusForSummaryFragmentDoc,
"\n fragment TriggeredAutomationsStatusSummary on TriggeredAutomationsStatus {\n id\n automationRuns {\n id\n functionRuns {\n id\n ...FunctionRunStatusForSummary\n }\n }\n }\n": types.TriggeredAutomationsStatusSummaryFragmentDoc,
"\n fragment AutomationRunDetails on AutomateRun {\n id\n status\n functionRuns {\n ...FunctionRunStatusForSummary\n statusMessage\n }\n trigger {\n ... on VersionCreatedTrigger {\n version {\n id\n }\n model {\n id\n }\n }\n }\n createdAt\n updatedAt\n }\n": types.AutomationRunDetailsFragmentDoc,
@@ -688,7 +672,7 @@ const documents: Documents = {
"\n mutation TriggerAutomation($projectId: ID!, $automationId: ID!) {\n projectMutations {\n automationMutations(projectId: $projectId) {\n trigger(automationId: $automationId)\n }\n }\n }\n": types.TriggerAutomationDocument,
"\n mutation CreateTestAutomation(\n $projectId: ID!\n $input: ProjectTestAutomationCreateInput!\n ) {\n projectMutations {\n automationMutations(projectId: $projectId) {\n createTestAutomation(input: $input) {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n }\n }\n }\n": types.CreateTestAutomationDocument,
"\n mutation MoveProjectToWorkspace($workspaceId: String!, $projectId: String!) {\n workspaceMutations {\n projects {\n moveToWorkspace(workspaceId: $workspaceId, projectId: $projectId) {\n id\n workspace {\n id\n projects {\n items {\n id\n }\n }\n ...ProjectsMoveToWorkspaceDialog_Workspace\n ...MoveProjectsDialog_Workspace\n }\n }\n }\n }\n }\n": types.MoveProjectToWorkspaceDocument,
"\n query ProjectAccessCheck($id: String!) {\n project(id: $id) {\n id\n visibility\n workspace {\n id\n slug\n }\n }\n }\n": types.ProjectAccessCheckDocument,
"\n query ProjectAccessCheck($id: String!) {\n project(id: $id) {\n id\n permissions {\n canRead {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.ProjectAccessCheckDocument,
"\n query ProjectRoleCheck($id: String!) {\n project(id: $id) {\n id\n role\n }\n }\n": types.ProjectRoleCheckDocument,
"\n query ProjectsDashboardQuery($filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(filter: $filter, limit: 6, cursor: $cursor) {\n ...ProjectsDashboard_UserProjectCollection\n cursor\n totalCount\n items {\n ...ProjectDashboardItem\n }\n }\n ...ProjectsHiddenProjectWarning_User\n ...ProjectsDashboardHeaderProjects_User\n }\n }\n": types.ProjectsDashboardQueryDocument,
"\n query ProjectsDashboardWorkspaceQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n": types.ProjectsDashboardWorkspaceQueryDocument,
@@ -1460,6 +1444,10 @@ export function graphql(source: "\n query ActiveUserMainMetadata {\n activeU
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation CreateOnboardingProject {\n projectMutations {\n createForOnboarding {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n "): (typeof documents)["\n mutation CreateOnboardingProject {\n projectMutations {\n createForOnboarding {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n "];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment FullPermissionCheckResult on PermissionCheckResult {\n authorized\n code\n message\n payload\n }\n"): (typeof documents)["\n fragment FullPermissionCheckResult on PermissionCheckResult {\n authorized\n code\n message\n payload\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1496,42 +1484,6 @@ export function graphql(source: "\n query ActiveUserActiveWorkspaceCheck {\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 query projectWorkspaceAccessCheck($projectId: String!) {\n project(id: $projectId) {\n id\n role\n workspace {\n id\n slug\n role\n }\n }\n }\n"): (typeof documents)["\n query projectWorkspaceAccessCheck($projectId: String!) {\n project(id: $projectId) {\n id\n role\n workspace {\n id\n slug\n role\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query AuthzProjectMetadata($id: String!) {\n project(id: $id) {\n id\n ...AuthzGetProject_Project\n ...AuthzGetProjectRole_Project\n }\n }\n"): (typeof documents)["\n query AuthzProjectMetadata($id: String!) {\n project(id: $id) {\n id\n ...AuthzGetProject_Project\n ...AuthzGetProjectRole_Project\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment AuthzGetProject_Project on Project {\n id\n visibility\n workspaceId\n }\n"): (typeof documents)["\n fragment AuthzGetProject_Project on Project {\n id\n visibility\n workspaceId\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment AuthzGetProjectRole_Project on Project {\n id\n role\n }\n"): (typeof documents)["\n fragment AuthzGetProjectRole_Project on Project {\n id\n role\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query AuthzServerMetadata {\n activeUser {\n id\n ...AuthzGetServerRole_User\n }\n }\n"): (typeof documents)["\n query AuthzServerMetadata {\n activeUser {\n id\n ...AuthzGetServerRole_User\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment AuthzGetServerRole_User on User {\n id\n role\n }\n"): (typeof documents)["\n fragment AuthzGetServerRole_User on User {\n id\n role\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query AuthzWorkspaceMetadata($id: String!) {\n workspace(id: $id) {\n id\n ...AuthzGetWorkspace_Workspace\n ...AuthzGetWorkspaceRole_Workspace\n ...AuthzGetWorkspaceSsoProviderSession_Workspace\n }\n }\n"): (typeof documents)["\n query AuthzWorkspaceMetadata($id: String!) {\n workspace(id: $id) {\n id\n ...AuthzGetWorkspace_Workspace\n ...AuthzGetWorkspaceRole_Workspace\n ...AuthzGetWorkspaceSsoProviderSession_Workspace\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment AuthzGetWorkspace_Workspace on Workspace {\n id\n slug\n }\n"): (typeof documents)["\n fragment AuthzGetWorkspace_Workspace on Workspace {\n id\n slug\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment AuthzGetWorkspaceRole_Workspace on Workspace {\n id\n role\n }\n"): (typeof documents)["\n fragment AuthzGetWorkspaceRole_Workspace on Workspace {\n id\n role\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment AuthzGetWorkspaceSsoProviderSession_Workspace on Workspace {\n id\n sso {\n provider {\n id\n }\n session {\n validUntil\n }\n }\n }\n"): (typeof documents)["\n fragment AuthzGetWorkspaceSsoProviderSession_Workspace on Workspace {\n id\n sso {\n provider {\n id\n }\n session {\n validUntil\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1883,7 +1835,7 @@ export function graphql(source: "\n mutation MoveProjectToWorkspace($workspaceI
/**
* 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 ProjectAccessCheck($id: String!) {\n project(id: $id) {\n id\n visibility\n workspace {\n id\n slug\n }\n }\n }\n"): (typeof documents)["\n query ProjectAccessCheck($id: String!) {\n project(id: $id) {\n id\n visibility\n workspace {\n id\n slug\n }\n }\n }\n"];
export function graphql(source: "\n query ProjectAccessCheck($id: String!) {\n project(id: $id) {\n id\n permissions {\n canRead {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectAccessCheck($id: String!) {\n project(id: $id) {\n id\n permissions {\n canRead {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -34,7 +34,8 @@ import { base64Encode } from '~/lib/common/helpers/encodeDecode'
import type { ErrorResponse } from '@apollo/client/link/error'
import type {
AllObjectFieldArgTypes,
AllObjectTypes
AllObjectTypes,
PermissionCheckResult
} from '~/lib/common/generated/gql/graphql'
/**
@@ -754,18 +755,60 @@ export const modifyObjectField = <
export const hasErrorWith = (params: {
errors: readonly GraphQLFormattedError[] | undefined
code?: string
codes?: string[]
codes?: Array<string | RegExp>
message?: string
}) => {
const { errors, code, message, codes } = params
const { errors, message, codes } = params
if (!errors?.length) return undefined
if (!code?.length && !message?.length && !codes?.length) return undefined
if (!message?.length && !codes?.length) return undefined
return errors.find((e) => {
const hasCode = code && e.extensions?.code === code
const hasMessage = message && e.message.toLowerCase().includes(message)
const hasCodes = codes && codes.some((c) => e.extensions?.code === c)
return hasCode || hasMessage || hasCodes
const hasCodes =
codes &&
codes.some((testCode) => {
const code = e.extensions?.code
if (!code || !isString(code)) return false
return isString(testCode) ? code === testCode : code.match(testCode)
})
return hasMessage || hasCodes
})
}
export const errorsToAuthResult = (params: {
errors: readonly GraphQLFormattedError[] | undefined
}): PermissionCheckResult => {
const { errors } = params
if (!errors?.length) return { authorized: true, message: 'OK', code: 'OK' }
// Prioritize common error codes
const commonAuthError = hasErrorWith({
errors,
codes: [
/forbidden/i,
/unauthorized/i,
/not[_\s]found/i,
/not[_\s]authorized/i,
'SSO_SESSION_MISSING_OR_EXPIRED_ERROR'
]
})
if (commonAuthError) {
return {
authorized: false,
code: commonAuthError.extensions!.code as string,
message: commonAuthError.message,
payload: commonAuthError.extensions || null
}
}
const firstError = errors[0]
return {
authorized: false,
code:
firstError.extensions?.code && isString(firstError.extensions?.code)
? firstError.extensions?.code
: 'UNKNOWN_ERROR',
message: firstError.message,
payload: firstError.extensions || null
}
}
@@ -4,10 +4,10 @@ export const projectAccessCheckQuery = graphql(`
query ProjectAccessCheck($id: String!) {
project(id: $id) {
id
visibility
workspace {
id
slug
permissions {
canRead {
...FullPermissionCheckResult
}
}
}
}
@@ -1,53 +1,65 @@
import { throwUncoveredError } from '@speckle/shared'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '@speckle/shared/authz'
import { useAuthPolicies } from '~/lib/auth/composables/authPolicies'
import { ActiveUserId } from '~/lib/auth/helpers/authPolicies'
import { loginRoute } from '~/lib/common/helpers/route'
import type { Optional } from '@speckle/shared'
import { useApolloClientFromNuxt } from '~/lib/common/composables/graphql'
import { errorsToAuthResult } from '~/lib/common/helpers/graphql'
import { projectAccessCheckQuery } from '~/lib/projects/graphql/queries'
import { WorkspaceSsoErrorCodes } from '~/lib/workspaces/helpers/types'
/**
* Used in project page to validate that project ID refers to a valid project and redirects to 404 if not
*/
export default defineNuxtRouteMiddleware(async (to) => {
const projectId = to.params.id as string
const authPolicies = useAuthPolicies()
const canAccess = await authPolicies.noCache().project.canQuery({
projectId,
userId: ActiveUserId
})
if (canAccess.isErr) {
switch (canAccess.error.code) {
case WorkspaceSsoSessionNoAccessError.code: {
const client = useApolloClientFromNuxt()
const { data, errors } = await client
.query({
query: projectAccessCheckQuery,
variables: { id: projectId },
context: {
skipLoggingErrors: true
},
fetchPolicy: 'network-only'
})
.catch(convertThrowIntoFetchResult)
// we may not even get to the authResult because of project() resolver errors, hence the mapping
// from errors to authResult
const authResult = data?.project.permissions.canRead || errorsToAuthResult({ errors })
if (!authResult.authorized) {
switch (authResult.code) {
case WorkspaceSsoErrorCodes.SESSION_MISSING_OR_EXPIRED: {
// Redirect to the SSO error page
const workspaceSlug = canAccess.error.payload.workspaceSlug
return navigateTo(`/workspaces/${workspaceSlug}/sso/session-error`)
const payload = authResult.payload as Optional<{
workspaceSlug: string
}>
const workspaceSlug = payload?.workspaceSlug
if (workspaceSlug) {
return navigateTo(`/workspaces/${workspaceSlug}/sso/session-error`)
}
}
case ProjectNotFoundError.code: {
return abortNavigation(
createError({ statusCode: 404, message: 'Project not found' })
)
}
case WorkspaceNoAccessError.code:
case ProjectNoAccessError.code: {
// eslint-disable-next-line no-fallthrough
case 'FORBIDDEN':
return abortNavigation(
createError({
statusCode: 403,
message: 'You do not have access to this project'
message: authResult.message
})
)
case 'STREAM_NOT_FOUND':
return abortNavigation(
createError({
statusCode: 404,
message: authResult.message
})
)
default:
return abortNavigation(
createError({
statusCode: 500,
message: authResult.message
})
)
}
case ServerNoAccessError.code:
case ServerNoSessionError.code:
return navigateTo(loginRoute)
default: {
throwUncoveredError(canAccess.error)
}
}
}
})
@@ -1,4 +1,3 @@
import type { Optional } from '@speckle/shared'
import { activeUserQuery } from '~/lib/auth/composables/activeUser'
import {
authLoginPanelQuery,
@@ -6,8 +5,6 @@ import {
} from '~/lib/auth/graphql/queries'
import { usePreloadApolloQueries } from '~/lib/common/composables/graphql'
import { mainServerInfoDataQuery } from '~/lib/core/composables/server'
import { projectAccessCheckQuery } from '~/lib/projects/graphql/queries'
import { workspaceAccessCheckQuery } from '~/lib/workspaces/graphql/queries'
/**
* Prefetches data for specific routes to avoid the problem of serial API requests
@@ -24,9 +21,6 @@ export default defineNuxtPlugin(async (ctx) => {
return
}
const path = route.path
const idParam = route.params.id as Optional<string>
const slugParam = route.params.slug as Optional<string>
const promises: Promise<unknown>[] = []
// Standard/global
@@ -36,36 +30,6 @@ export default defineNuxtPlugin(async (ctx) => {
})
)
// Preload project data
if (idParam && path.startsWith('/projects/')) {
promises.push(
preload({
queries: [
{
query: projectAccessCheckQuery,
variables: { id: idParam },
context: { skipLoggingErrors: true }
}
]
})
)
}
// Preload workspace data
if (slugParam && path.startsWith('/workspaces/') && isWorkspacesEnabled.value) {
promises.push(
preload({
queries: [
{
query: workspaceAccessCheckQuery,
variables: { slug: slugParam },
context: { skipLoggingErrors: true }
}
]
})
)
}
// Preload viewer data
if (route.meta.key === '/projects/:id/models/resources') {
// Unable to preload this from vue components due to SSR being essentially turned off for the viewer
@@ -1,30 +0,0 @@
import type { NuxtApp } from '#app'
import { Authz } from '@speckle/shared'
import { buildAuthPolicyLoaders } from '~/lib/auth/loaders/index'
/**
* Set up @speckle/shared authPolicies & their loaders
*/
export default defineNuxtPlugin(async (nuxt) => {
const nuxtApp = nuxt as NuxtApp
const loaders = buildAuthPolicyLoaders({ nuxtApp })
return {
provide: {
authPolicies: {
...Authz.authPoliciesFactory(loaders),
/**
* Skips Apollo Cache the first time a query is requested. Useful in middlewares
* where we want a fresh check to be invoked every time
*/
noCache: () =>
Authz.authPoliciesFactory(
buildAuthPolicyLoaders({
nuxtApp,
options: { noCache: true }
})
)
}
}
}
})
@@ -39,3 +39,10 @@ type Price {
currency: String!
currencySymbol: String!
}
type PermissionCheckResult {
authorized: Boolean!
code: String!
message: String!
payload: JSONObject
}
@@ -0,0 +1,7 @@
extend type Project {
permissions: ProjectPermissionChecks!
}
type ProjectPermissionChecks {
canRead: PermissionCheckResult!
}
@@ -207,6 +207,7 @@ input UserProjectsFilter {
Only include projects where user has the specified roles
"""
onlyWithRoles: [String!]
workspaceId: ID
}
@@ -0,0 +1,7 @@
extend type Workspace {
permissions: WorkspacePermissionChecks!
}
type WorkspacePermissionChecks {
canCreateProject: PermissionCheckResult!
}
+2
View File
@@ -86,6 +86,8 @@ generates:
ServerRegionItem: '@/modules/multiregion/helpers/graphTypes#ServerRegionItemGraphQLReturn'
Price: '@/modules/gatekeeperCore/helpers/graphTypes#PriceGraphQLReturn'
WorkspaceSubscription: '@/modules/gatekeeper/helpers/graphTypes#WorkspaceSubscriptionGraphQLReturn'
ProjectPermissionChecks: '@/modules/core/helpers/graphTypes#ProjectPermissionChecksGraphQLReturn'
WorkspacePermissionChecks: '@/modules/workspacesCore/helpers/graphTypes#WorkspacePermissionChecksGraphQLReturn'
modules/cross-server-sync/graph/generated/graphql.ts:
plugins:
- 'typescript'
@@ -2,30 +2,22 @@ import { defineModuleLoaders } from '@/modules/loaders'
import { getStreamFactory } from '@/modules/core/repositories/streams'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { db } from '@/db/knex'
import { getUserServerRoleFactory } from '@/modules/shared/repositories/acl'
import { err, ok } from 'true-myth/result'
import { Authz } from '@speckle/shared'
// TODO: Move everything to use dataLoaders
export default defineModuleLoaders(async () => {
const getStream = getStreamFactory({ db })
const getUserServerRole = getUserServerRoleFactory({ db })
return {
getEnv: async () => getFeatureFlags(),
getProject: async ({ projectId }) => {
const project = await getStream({ streamId: projectId })
if (!project) return err(new Authz.ProjectNotFoundError())
return ok({ ...project, projectId: project.id })
getProject: async ({ projectId }, { dataLoaders }) => {
return await dataLoaders.streams.getStream.load(projectId)
},
getProjectRole: async ({ userId, projectId }) => {
const project = await getStream({ streamId: projectId, userId })
if (!project?.role) return err(new Authz.ProjectRoleNotFoundError())
return ok(project.role)
return project?.role || null
},
getServerRole: async ({ userId }) => {
const role = await getUserServerRole({ userId })
if (!role) return err(new Authz.ServerRoleNotFoundError())
return ok(role)
getServerRole: async ({ userId }, { dataLoaders }) => {
return (await dataLoaders.users.getUser.load(userId))?.role || null
}
}
})
@@ -1,11 +1,11 @@
import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql';
import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, ObjectGraphQLReturn, VersionGraphQLReturn, ServerInviteGraphQLReturnType, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, MutationsObjectGraphQLReturn, LimitedUserGraphQLReturn, UserGraphQLReturn, GraphQLEmptyReturn, StreamCollaboratorGraphQLReturn, ProjectCollaboratorGraphQLReturn, ServerInfoGraphQLReturn, BranchGraphQLReturn } from '@/modules/core/helpers/graphTypes';
import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, ObjectGraphQLReturn, VersionGraphQLReturn, ServerInviteGraphQLReturnType, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, MutationsObjectGraphQLReturn, LimitedUserGraphQLReturn, UserGraphQLReturn, GraphQLEmptyReturn, StreamCollaboratorGraphQLReturn, ProjectCollaboratorGraphQLReturn, ServerInfoGraphQLReturn, BranchGraphQLReturn, ProjectPermissionChecksGraphQLReturn } from '@/modules/core/helpers/graphTypes';
import { StreamAccessRequestGraphQLReturn, ProjectAccessRequestGraphQLReturn } from '@/modules/accessrequests/helpers/graphTypes';
import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn } 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 { WorkspaceGraphQLReturn, WorkspaceSsoGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceJoinRequestMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, WorkspaceJoinRequestGraphQLReturn, LimitedWorkspaceJoinRequestGraphQLReturn, ProjectRoleGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes';
import { WorkspaceGraphQLReturn, WorkspaceSsoGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceJoinRequestMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, WorkspaceJoinRequestGraphQLReturn, LimitedWorkspaceJoinRequestGraphQLReturn, ProjectRoleGraphQLReturn, WorkspacePermissionChecksGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes';
import { WorkspaceBillingMutationsGraphQLReturn, WorkspaceSubscriptionGraphQLReturn } from '@/modules/gatekeeper/helpers/graphTypes';
import { WebhookGraphQLReturn } from '@/modules/webhooks/helpers/graphTypes';
import { SmartTextEditorValueGraphQLReturn } from '@/modules/core/services/richTextEditorService';
@@ -1984,6 +1984,14 @@ export type PendingWorkspaceCollaboratorsFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
export type PermissionCheckResult = {
__typename?: 'PermissionCheckResult';
authorized: Scalars['Boolean']['output'];
code: Scalars['String']['output'];
message: Scalars['String']['output'];
payload?: Maybe<Scalars['JSONObject']['output']>;
};
export type Price = {
__typename?: 'Price';
amount: Scalars['Float']['output'];
@@ -2029,6 +2037,7 @@ export type Project = {
pendingAccessRequests?: Maybe<Array<ProjectAccessRequest>>;
/** Returns a list models that are being created from a file import */
pendingImportedModels: Array<FileUpload>;
permissions: ProjectPermissionChecks;
/** Active user's role for this project. `null` if request is not authenticated, or the project is not explicitly shared with you. */
role?: Maybe<Scalars['String']['output']>;
/** Source apps used in any models of this project */
@@ -2529,6 +2538,11 @@ export const ProjectPendingVersionsUpdatedMessageType = {
} as const;
export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVersionsUpdatedMessageType[keyof typeof ProjectPendingVersionsUpdatedMessageType];
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canRead: PermissionCheckResult;
};
export type ProjectRole = {
__typename?: 'ProjectRole';
project: Project;
@@ -4359,6 +4373,7 @@ export type Workspace = {
logo?: Maybe<Scalars['String']['output']>;
membersByRole?: Maybe<WorkspaceMembersByRole>;
name: Scalars['String']['output'];
permissions: WorkspacePermissionChecks;
plan?: Maybe<WorkspacePlan>;
projects: ProjectCollection;
/** A Workspace is marked as readOnly if its trial period is finished or a paid plan is subscribed but payment has failed */
@@ -4723,6 +4738,11 @@ export const WorkspacePaymentMethod = {
} as const;
export type WorkspacePaymentMethod = typeof WorkspacePaymentMethod[keyof typeof WorkspacePaymentMethod];
export type WorkspacePermissionChecks = {
__typename?: 'WorkspacePermissionChecks';
canCreateProject: PermissionCheckResult;
};
export type WorkspacePlan = {
__typename?: 'WorkspacePlan';
createdAt: Scalars['DateTime']['output'];
@@ -5173,6 +5193,7 @@ export type ResolversTypes = {
PendingStreamCollaborator: ResolverTypeWrapper<PendingStreamCollaboratorGraphQLReturn>;
PendingWorkspaceCollaborator: ResolverTypeWrapper<PendingWorkspaceCollaboratorGraphQLReturn>;
PendingWorkspaceCollaboratorsFilter: PendingWorkspaceCollaboratorsFilter;
PermissionCheckResult: ResolverTypeWrapper<PermissionCheckResult>;
Price: ResolverTypeWrapper<PriceGraphQLReturn>;
Project: ResolverTypeWrapper<ProjectGraphQLReturn>;
ProjectAccessRequest: ResolverTypeWrapper<ProjectAccessRequestGraphQLReturn>;
@@ -5204,6 +5225,7 @@ export type ResolversTypes = {
ProjectPendingModelsUpdatedMessageType: ProjectPendingModelsUpdatedMessageType;
ProjectPendingVersionsUpdatedMessage: ResolverTypeWrapper<Omit<ProjectPendingVersionsUpdatedMessage, 'version'> & { version: ResolversTypes['FileUpload'] }>;
ProjectPendingVersionsUpdatedMessageType: ProjectPendingVersionsUpdatedMessageType;
ProjectPermissionChecks: ResolverTypeWrapper<ProjectPermissionChecksGraphQLReturn>;
ProjectRole: ResolverTypeWrapper<ProjectRoleGraphQLReturn>;
ProjectTestAutomationCreateInput: ProjectTestAutomationCreateInput;
ProjectTriggeredAutomationsStatusUpdatedMessage: ResolverTypeWrapper<ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn>;
@@ -5330,6 +5352,7 @@ export type ResolversTypes = {
WorkspaceMembersByRole: ResolverTypeWrapper<WorkspaceMembersByRole>;
WorkspaceMutations: ResolverTypeWrapper<WorkspaceMutationsGraphQLReturn>;
WorkspacePaymentMethod: WorkspacePaymentMethod;
WorkspacePermissionChecks: ResolverTypeWrapper<WorkspacePermissionChecksGraphQLReturn>;
WorkspacePlan: ResolverTypeWrapper<WorkspacePlan>;
WorkspacePlanPrice: ResolverTypeWrapper<Omit<WorkspacePlanPrice, 'monthly' | 'yearly'> & { monthly?: Maybe<ResolversTypes['Price']>, yearly?: Maybe<ResolversTypes['Price']> }>;
WorkspacePlanStatuses: WorkspacePlanStatuses;
@@ -5491,6 +5514,7 @@ export type ResolversParentTypes = {
PendingStreamCollaborator: PendingStreamCollaboratorGraphQLReturn;
PendingWorkspaceCollaborator: PendingWorkspaceCollaboratorGraphQLReturn;
PendingWorkspaceCollaboratorsFilter: PendingWorkspaceCollaboratorsFilter;
PermissionCheckResult: PermissionCheckResult;
Price: PriceGraphQLReturn;
Project: ProjectGraphQLReturn;
ProjectAccessRequest: ProjectAccessRequestGraphQLReturn;
@@ -5516,6 +5540,7 @@ export type ResolversParentTypes = {
ProjectMutations: MutationsObjectGraphQLReturn;
ProjectPendingModelsUpdatedMessage: Omit<ProjectPendingModelsUpdatedMessage, 'model'> & { model: ResolversParentTypes['FileUpload'] };
ProjectPendingVersionsUpdatedMessage: Omit<ProjectPendingVersionsUpdatedMessage, 'version'> & { version: ResolversParentTypes['FileUpload'] };
ProjectPermissionChecks: ProjectPermissionChecksGraphQLReturn;
ProjectRole: ProjectRoleGraphQLReturn;
ProjectTestAutomationCreateInput: ProjectTestAutomationCreateInput;
ProjectTriggeredAutomationsStatusUpdatedMessage: ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn;
@@ -5626,6 +5651,7 @@ export type ResolversParentTypes = {
WorkspaceJoinRequestMutations: WorkspaceJoinRequestMutationsGraphQLReturn;
WorkspaceMembersByRole: WorkspaceMembersByRole;
WorkspaceMutations: WorkspaceMutationsGraphQLReturn;
WorkspacePermissionChecks: WorkspacePermissionChecksGraphQLReturn;
WorkspacePlan: WorkspacePlan;
WorkspacePlanPrice: Omit<WorkspacePlanPrice, 'monthly' | 'yearly'> & { monthly?: Maybe<ResolversParentTypes['Price']>, yearly?: Maybe<ResolversParentTypes['Price']> };
WorkspaceProjectCreateInput: WorkspaceProjectCreateInput;
@@ -6389,6 +6415,14 @@ export type PendingWorkspaceCollaboratorResolvers<ContextType = GraphQLContext,
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type PermissionCheckResultResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['PermissionCheckResult'] = ResolversParentTypes['PermissionCheckResult']> = {
authorized?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
code?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
message?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
payload?: Resolver<Maybe<ResolversTypes['JSONObject']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type PriceResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['Price'] = ResolversParentTypes['Price']> = {
amount?: Resolver<ResolversTypes['Float'], ParentType, ContextType>;
currency?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@@ -6418,6 +6452,7 @@ export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends Re
object?: Resolver<Maybe<ResolversTypes['Object']>, ParentType, ContextType, RequireFields<ProjectObjectArgs, 'id'>>;
pendingAccessRequests?: Resolver<Maybe<Array<ResolversTypes['ProjectAccessRequest']>>, ParentType, ContextType>;
pendingImportedModels?: Resolver<Array<ResolversTypes['FileUpload']>, ParentType, ContextType, RequireFields<ProjectPendingImportedModelsArgs, 'limit'>>;
permissions?: Resolver<ResolversTypes['ProjectPermissionChecks'], ParentType, ContextType>;
role?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
sourceApps?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
team?: Resolver<Array<ResolversTypes['ProjectCollaborator']>, ParentType, ContextType>;
@@ -6547,6 +6582,11 @@ export type ProjectPendingVersionsUpdatedMessageResolvers<ContextType = GraphQLC
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ProjectPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ProjectPermissionChecks'] = ResolversParentTypes['ProjectPermissionChecks']> = {
canRead?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ProjectRoleResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ProjectRole'] = ResolversParentTypes['ProjectRole']> = {
project?: Resolver<ResolversTypes['Project'], ParentType, ContextType>;
role?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@@ -7135,6 +7175,7 @@ export type WorkspaceResolvers<ContextType = GraphQLContext, ParentType extends
logo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
membersByRole?: Resolver<Maybe<ResolversTypes['WorkspaceMembersByRole']>, ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
permissions?: Resolver<ResolversTypes['WorkspacePermissionChecks'], ParentType, ContextType>;
plan?: Resolver<Maybe<ResolversTypes['WorkspacePlan']>, ParentType, ContextType>;
projects?: Resolver<ResolversTypes['ProjectCollection'], ParentType, ContextType, RequireFields<WorkspaceProjectsArgs, 'limit'>>;
readOnly?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
@@ -7251,6 +7292,11 @@ export type WorkspaceMutationsResolvers<ContextType = GraphQLContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspacePermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspacePermissionChecks'] = ResolversParentTypes['WorkspacePermissionChecks']> = {
canCreateProject?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspacePlanResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspacePlan'] = ResolversParentTypes['WorkspacePlan']> = {
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['WorkspacePlans'], ParentType, ContextType>;
@@ -7413,6 +7459,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
PasswordStrengthCheckResults?: PasswordStrengthCheckResultsResolvers<ContextType>;
PendingStreamCollaborator?: PendingStreamCollaboratorResolvers<ContextType>;
PendingWorkspaceCollaborator?: PendingWorkspaceCollaboratorResolvers<ContextType>;
PermissionCheckResult?: PermissionCheckResultResolvers<ContextType>;
Price?: PriceResolvers<ContextType>;
Project?: ProjectResolvers<ContextType>;
ProjectAccessRequest?: ProjectAccessRequestResolvers<ContextType>;
@@ -7429,6 +7476,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
ProjectMutations?: ProjectMutationsResolvers<ContextType>;
ProjectPendingModelsUpdatedMessage?: ProjectPendingModelsUpdatedMessageResolvers<ContextType>;
ProjectPendingVersionsUpdatedMessage?: ProjectPendingVersionsUpdatedMessageResolvers<ContextType>;
ProjectPermissionChecks?: ProjectPermissionChecksResolvers<ContextType>;
ProjectRole?: ProjectRoleResolvers<ContextType>;
ProjectTriggeredAutomationsStatusUpdatedMessage?: ProjectTriggeredAutomationsStatusUpdatedMessageResolvers<ContextType>;
ProjectUpdatedMessage?: ProjectUpdatedMessageResolvers<ContextType>;
@@ -7498,6 +7546,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
WorkspaceJoinRequestMutations?: WorkspaceJoinRequestMutationsResolvers<ContextType>;
WorkspaceMembersByRole?: WorkspaceMembersByRoleResolvers<ContextType>;
WorkspaceMutations?: WorkspaceMutationsResolvers<ContextType>;
WorkspacePermissionChecks?: WorkspacePermissionChecksResolvers<ContextType>;
WorkspacePlan?: WorkspacePlanResolvers<ContextType>;
WorkspacePlanPrice?: WorkspacePlanPriceResolvers<ContextType>;
WorkspaceProjectMutations?: WorkspaceProjectMutationsResolvers<ContextType>;
@@ -0,0 +1,17 @@
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { Authz } from '@speckle/shared'
export default {
Project: {
permissions: (parent) => ({ projectId: parent.id })
},
ProjectPermissionChecks: {
canRead: async (parent, _args, ctx) => {
const canRead = await ctx.authPolicies.project.canRead({
projectId: parent.projectId,
userId: ctx.userId
})
return Authz.toGraphqlResult(canRead)
}
}
} as Resolvers
@@ -91,6 +91,7 @@ import { has } from 'lodash'
import { throwUncoveredError } from '@speckle/shared'
import { ForbiddenError } from '@/modules/shared/errors'
import { Authz } from '@speckle/shared'
import { SsoSessionMissingOrExpiredError } from '@/modules/workspacesCore/errors'
const getServerInfo = getServerInfoFactory({ db })
const getUsers = getUsersFactory({ db })
@@ -179,7 +180,7 @@ const getUserStreamsCount = getUserStreamsCountFactory({ db })
export = {
Query: {
async project(_parent, args, context) {
const canQuery = await context.authPolicies.project.canQuery({
const canQuery = await context.authPolicies.project.canRead({
projectId: args.id,
userId: context.userId
})
@@ -187,11 +188,16 @@ export = {
if (!canQuery.isOk) {
switch (canQuery.error.code) {
case Authz.ProjectNotFoundError.code:
throw new StreamNotFoundError()
throw new StreamNotFoundError('Project not found')
case Authz.ProjectNoAccessError.code:
case Authz.WorkspaceNoAccessError.code:
case Authz.WorkspaceSsoSessionNoAccessError.code:
throw new ForbiddenError(canQuery.error.message)
case Authz.WorkspaceSsoSessionNoAccessError.code:
throw new SsoSessionMissingOrExpiredError(canQuery.error.message, {
info: {
workspaceSlug: canQuery.error.payload.workspaceSlug
}
})
case Authz.ServerNoAccessError.code:
case Authz.ServerNoSessionError.code:
throw new ForbiddenError(canQuery.error.message)
@@ -136,3 +136,7 @@ export type ProjectCollaboratorGraphQLReturn = {
role: StreamRoles
projectId: string
}
export type ProjectPermissionChecksGraphQLReturn = {
projectId: string
}
@@ -1964,6 +1964,14 @@ export type PendingWorkspaceCollaboratorsFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
export type PermissionCheckResult = {
__typename?: 'PermissionCheckResult';
authorized: Scalars['Boolean']['output'];
code: Scalars['String']['output'];
message: Scalars['String']['output'];
payload?: Maybe<Scalars['JSONObject']['output']>;
};
export type Price = {
__typename?: 'Price';
amount: Scalars['Float']['output'];
@@ -2009,6 +2017,7 @@ export type Project = {
pendingAccessRequests?: Maybe<Array<ProjectAccessRequest>>;
/** Returns a list models that are being created from a file import */
pendingImportedModels: Array<FileUpload>;
permissions: ProjectPermissionChecks;
/** Active user's role for this project. `null` if request is not authenticated, or the project is not explicitly shared with you. */
role?: Maybe<Scalars['String']['output']>;
/** Source apps used in any models of this project */
@@ -2509,6 +2518,11 @@ export const ProjectPendingVersionsUpdatedMessageType = {
} as const;
export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVersionsUpdatedMessageType[keyof typeof ProjectPendingVersionsUpdatedMessageType];
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canRead: PermissionCheckResult;
};
export type ProjectRole = {
__typename?: 'ProjectRole';
project: Project;
@@ -4339,6 +4353,7 @@ export type Workspace = {
logo?: Maybe<Scalars['String']['output']>;
membersByRole?: Maybe<WorkspaceMembersByRole>;
name: Scalars['String']['output'];
permissions: WorkspacePermissionChecks;
plan?: Maybe<WorkspacePlan>;
projects: ProjectCollection;
/** A Workspace is marked as readOnly if its trial period is finished or a paid plan is subscribed but payment has failed */
@@ -4703,6 +4718,11 @@ export const WorkspacePaymentMethod = {
} as const;
export type WorkspacePaymentMethod = typeof WorkspacePaymentMethod[keyof typeof WorkspacePaymentMethod];
export type WorkspacePermissionChecks = {
__typename?: 'WorkspacePermissionChecks';
canCreateProject: PermissionCheckResult;
};
export type WorkspacePlan = {
__typename?: 'WorkspacePlan';
createdAt: Scalars['DateTime']['output'];
@@ -1,8 +1,4 @@
import {
PaidWorkspacePlan,
TrialWorkspacePlan,
UnpaidWorkspacePlan,
WorkspacePlan,
WorkspacePlanProductPrices,
WorkspacePricingProducts
} from '@/modules/gatekeeperCore/domain/billing'
@@ -10,7 +6,11 @@ import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import {
Nullable,
Optional,
PaidWorkspacePlan,
PaidWorkspacePlans,
TrialWorkspacePlan,
UnpaidWorkspacePlan,
WorkspacePlan,
WorkspacePlanBillingIntervals
} from '@speckle/shared'
import { OverrideProperties } from 'type-fest'
@@ -1,8 +1,8 @@
import { WorkspaceSeat } from '@/modules/gatekeeper/domain/billing'
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { Workspace } from '@/modules/workspacesCore/domain/types'
import {
Optional,
WorkspacePlan,
WorkspacePlanFeatures,
WorkspacePlans,
WorkspacePlanStatuses
@@ -1,9 +1,7 @@
import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe'
import {
getWorkspacePlanFactory,
getWorkspaceSubscriptionFactory,
upsertTrialWorkspacePlanFactory,
upsertUnpaidWorkspacePlanFactory
getWorkspaceSubscriptionFactory
} from '@/modules/gatekeeper/repositories/billing'
import { countSeatsByTypeInWorkspaceFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
import {
@@ -14,15 +12,12 @@ import {
getWorkspacePlanPriceId,
getWorkspacePlanProductId
} from '@/modules/gatekeeper/stripe'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { countWorkspaceRoleWithOptionalProjectRoleFactory } from '@/modules/workspaces/repositories/workspaces'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import { Knex } from 'knex'
import Stripe from 'stripe'
const { FF_GATEKEEPER_FORCE_FREE_PLAN } = getFeatureFlags()
export const initializeEventListenersFactory =
({ db, stripe }: { db: Knex; stripe: Stripe }) =>
() => {
@@ -64,28 +59,6 @@ export const initializeEventListenersFactory =
...payload.seat,
seatType: payload.seat.type
})
}),
eventBus.listen(WorkspaceEvents.Created, async ({ payload }) => {
// TODO: based on a feature flag, we can force new workspaces into the free plan here
if (FF_GATEKEEPER_FORCE_FREE_PLAN) {
await upsertUnpaidWorkspacePlanFactory({ db })({
workspacePlan: {
name: 'free',
status: 'valid',
workspaceId: payload.workspace.id,
createdAt: new Date()
}
})
} else {
await upsertTrialWorkspacePlanFactory({ db })({
workspacePlan: {
name: 'starter',
status: 'trial',
workspaceId: payload.workspace.id,
createdAt: new Date()
}
})
}
})
]
@@ -23,11 +23,14 @@ import {
GetWorkspacesByPlanDaysTillExpiry,
GetWorkspacePlanByProjectId
} from '@/modules/gatekeeper/domain/operations'
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper'
import { Workspace } from '@/modules/workspacesCore/domain/types'
import { Workspaces } from '@/modules/workspacesCore/helpers/db'
import { PaidWorkspacePlansNew, PaidWorkspacePlansOld } from '@speckle/shared'
import {
PaidWorkspacePlansNew,
PaidWorkspacePlansOld,
WorkspacePlan
} from '@speckle/shared'
import { Knex } from 'knex'
import { omit } from 'lodash'
@@ -1,23 +1,7 @@
import { GetWorkspacePlan } from '@/modules/gatekeeper/domain/billing'
import { GetWorkspacePlanByProjectId } from '@/modules/gatekeeper/domain/operations'
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { Workspace } from '@/modules/workspacesCore/domain/types'
import { throwUncoveredError } from '@speckle/shared'
const isWorkspacePlanStatusReadOnly = (status: WorkspacePlan['status']) => {
switch (status) {
case 'cancelationScheduled':
case 'valid':
case 'trial':
case 'paymentFailed':
return false
case 'expired':
case 'canceled':
return true
default:
throwUncoveredError(status)
}
}
import { isWorkspacePlanStatusReadOnly } from '@speckle/shared'
export const isWorkspaceReadOnlyFactory =
({ getWorkspacePlan }: { getWorkspacePlan: GetWorkspacePlan }) =>
@@ -22,11 +22,11 @@ import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/
import {
PaidWorkspacePlanStatuses,
throwUncoveredError,
WorkspacePlan,
WorkspaceRoles
} from '@speckle/shared'
import { cloneDeep, sum } from 'lodash'
import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations'
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
export const handleSubscriptionUpdateFactory =
({
@@ -1,10 +1,9 @@
import { UpsertWorkspacePlan } from '@/modules/gatekeeper/domain/billing'
import { InvalidWorkspacePlanStatus } from '@/modules/gatekeeper/errors/billing'
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { GetWorkspace } from '@/modules/workspaces/domain/operations'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { throwUncoveredError } from '@speckle/shared'
import { throwUncoveredError, WorkspacePlan } from '@speckle/shared'
export const updateWorkspacePlanFactory =
({
@@ -14,8 +14,11 @@ import {
WorkspaceSubscription
} from '@/modules/gatekeeper/domain/billing'
import { omit } from 'lodash'
import { PaidWorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { PaidWorkspacePlans, WorkspacePlanBillingIntervals } from '@speckle/shared'
import {
PaidWorkspacePlan,
PaidWorkspacePlans,
WorkspacePlanBillingIntervals
} from '@speckle/shared'
import {
startCheckoutSessionFactoryNew as startCheckoutSessionFactory,
startCheckoutSessionFactoryOld
@@ -1,5 +1,5 @@
import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/featureAuthorization'
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { WorkspacePlan } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
@@ -28,9 +28,8 @@ import {
createTestSubscriptionData,
createTestWorkspaceSubscription
} from '@/modules/gatekeeper/tests/helpers'
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { expectToThrow } from '@/test/assertionHelper'
import { throwUncoveredError } from '@speckle/shared'
import { throwUncoveredError, WorkspacePlan } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { omit } from 'lodash'
@@ -1,10 +1,10 @@
import { InvalidWorkspacePlanStatus } from '@/modules/gatekeeper/errors/billing'
import { updateWorkspacePlanFactory } from '@/modules/gatekeeper/services/workspacePlans'
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { WorkspaceWithOptionalRole } from '@/modules/workspacesCore/domain/types'
import { expectToThrow } from '@/test/assertionHelper'
import { WorkspacePlan } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { omit } from 'lodash'
@@ -1,10 +1,5 @@
import {
PaidWorkspacePlans,
PaidWorkspacePlanStatuses,
TrialEnabledPaidWorkspacePlans,
TrialWorkspacePlanStatuses,
UnpaidWorkspacePlans,
UnpaidWorkspacePlanStatuses,
WorkspacePlanBillingIntervals,
WorkspacePlans
} from '@speckle/shared'
@@ -15,27 +10,6 @@ import { OverrideProperties, SetOptional } from 'type-fest'
*/
export type WorkspacePricingProducts = PaidWorkspacePlans | 'guest'
type BaseWorkspacePlan = {
workspaceId: string
createdAt: Date
}
export type PaidWorkspacePlan = BaseWorkspacePlan & {
name: PaidWorkspacePlans
status: PaidWorkspacePlanStatuses
}
export type TrialWorkspacePlan = BaseWorkspacePlan & {
name: TrialEnabledPaidWorkspacePlans
status: TrialWorkspacePlanStatuses
}
export type UnpaidWorkspacePlan = BaseWorkspacePlan & {
name: UnpaidWorkspacePlans
status: UnpaidWorkspacePlanStatuses
}
export type WorkspacePlan = PaidWorkspacePlan | TrialWorkspacePlan | UnpaidWorkspacePlan
type WorkspacePlanProductsMetadata<PriceData = string> = OverrideProperties<
Record<
WorkspacePricingProducts,
@@ -1,4 +1,4 @@
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { WorkspacePlan } from '@speckle/shared'
export const gatekeeperEventNamespace = 'gatekeeper' as const
+42 -6
View File
@@ -4,7 +4,7 @@
import fs from 'fs'
import path from 'path'
import { appRoot, packageRoot } from '@/bootstrap'
import { values, merge, camelCase, reduce, intersection, difference } from 'lodash'
import { values, merge, camelCase, reduce, intersection, difference, set } from 'lodash'
import baseTypeDefs from '@/modules/core/graph/schema/baseTypeDefs'
import { scalarResolvers } from '@/modules/core/graph/scalars'
import { makeExecutableSchema } from '@graphql-tools/schema'
@@ -24,12 +24,17 @@ import { AppMocksConfig } from '@/modules/mocks'
import { SpeckleModuleMocksConfig } from '@/modules/shared/helpers/mocks'
import { LoaderConfigurationError, LogicError } from '@/modules/shared/errors'
import type { Registry } from 'prom-client'
import type { defineModuleLoaders } from '@/modules/loaders'
import type {
defineModuleLoaders,
ServerLoaders,
ServerLoadersContext
} from '@/modules/loaders'
import {
inMemoryCacheProviderFactory,
wrapWithCache
} from '@/modules/shared/utils/caching'
import TTLCache from '@isaacs/ttlcache'
import { buildRequestLoaders, RequestDataLoaders } from '@/modules/core/loaders'
/**
* Cached speckle module requires
@@ -134,7 +139,9 @@ export const init = async (params: { app: Express; metricsRegister: Registry })
}
// Validate & cache authz loaders
await moduleAuthLoaders()
await moduleAuthLoaders({
dataLoaders: undefined
})
hasInitializationOccurred = true
}
@@ -316,10 +323,16 @@ export const moduleMockConfigs = (
return mockConfigs
}
export const moduleAuthLoaders = async () => {
export const moduleAuthLoaders = async (params: {
dataLoaders?: RequestDataLoaders
}) => {
const enabledModuleNames = getEnabledModuleNames()
let loaders: Partial<Authz.AuthCheckContextLoaders> = {}
const dataLoaders = params.dataLoaders || (await buildRequestLoaders({ auth: false }))
const ctx: ServerLoadersContext = {
dataLoaders
}
// load auth loaders from /modules and in same order as the whitelist
const codeModuleDirs = fs.readdirSync(`${appRoot}/modules`)
@@ -334,9 +347,29 @@ export const moduleAuthLoaders = async () => {
.map((l) => l.default)
.filter(isNonNullable)[0] as Optional<ReturnType<typeof defineModuleLoaders>>
// Load the actual loaders
const newLoaders = await moduleLoadersBuilderFn?.()
const newServerLoaders: Partial<Authz.AuthCheckContextLoaders> = Object.entries(
newLoaders || {}
).reduce((acc, entry) => {
const key = entry[0] as Authz.AuthCheckContextLoaderKeys
const loader = entry[1] as Required<ServerLoaders>[typeof key]
// Feed in ctx to all loader functions
const wrappedLoader = (...args: any[]) => {
const newArgs = [...args, ctx]
return loader(...newArgs)
}
// Using set because of TS typing difficulty
set(acc, key, wrappedLoader)
return acc
}, {} as Partial<Authz.AuthCheckContextLoaders>)
loaders = {
...loaders,
...(await moduleLoadersBuilderFn?.())
...newServerLoaders
}
}
@@ -381,6 +414,9 @@ export const moduleAuthLoaders = async () => {
return {
loaders: loadersWithCache,
clearCache: () => cache.clear()
clearCache: () => {
cache.clear()
dataLoaders.clearAll()
}
}
}
+15 -3
View File
@@ -1,8 +1,20 @@
import { RequestDataLoaders } from '@/modules/core/loaders'
import { Authz, MaybeAsync } from '@speckle/shared'
export type ServerLoadersContext = {
dataLoaders: RequestDataLoaders
}
// Inject extra argument to all loaders, e.g. for GQL dataloaders
export type ServerLoaders = Partial<{
[K in keyof Authz.AuthCheckContextLoaders]: Authz.AuthCheckContextLoaders[K] extends (
...args: infer A
) => infer R
? (...args: [...A, ctx: ServerLoadersContext]) => R
: never
}>
// define being an arg simplifes usage in export default calls
export const defineModuleLoaders = (
define: () => MaybeAsync<Partial<Authz.AuthCheckContextLoaders>>
) => {
export const defineModuleLoaders = (define: () => MaybeAsync<ServerLoaders>) => {
return async () => await define()
}
@@ -209,10 +209,8 @@ export async function buildContext(params?: {
await wait(delay)
}
const [authLoaders, dataLoaders] = await Promise.all([
moduleAuthLoaders(),
buildRequestLoaders(ctx, { cleanLoadersEarly })
])
const dataLoaders = await buildRequestLoaders(ctx, { cleanLoadersEarly })
const authLoaders = await moduleAuthLoaders({ dataLoaders })
const authPolicies = Authz.authPoliciesFactory(authLoaders.loaders)
return {
@@ -1,46 +1,62 @@
import { db } from '@/db/knex'
import { getWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing'
import { defineModuleLoaders } from '@/modules/loaders'
import {
getUserSsoSessionFactory,
getWorkspaceSsoProviderRecordFactory
} from '@/modules/workspaces/repositories/sso'
import {
getWorkspaceFactory,
getWorkspaceRoleForUserFactory
} from '@/modules/workspaces/repositories/workspaces'
import { Authz } from '@speckle/shared'
import { err, ok } from 'true-myth/result'
import { getWorkspaceRoleForUserFactory } from '@/modules/workspaces/repositories/workspaces'
import { WorkspacePaidPlanConfigs, WorkspaceUnpaidPlanConfigs } from '@speckle/shared'
// TODO: Move everything to use dataLoaders
export default defineModuleLoaders(async () => {
const getWorkspace = getWorkspaceFactory({ db })
const getWorkspacePlan = getWorkspacePlanFactory({ db })
return {
getWorkspace: async ({ workspaceId }) => {
const workspace = await getWorkspace({ workspaceId })
if (!workspace) return err(new Authz.WorkspaceNotFoundError())
return ok(workspace)
getWorkspace: async ({ workspaceId }, { dataLoaders }) => {
return (await dataLoaders.workspaces!.getWorkspace.load(workspaceId)) || null
},
getWorkspaceRole: async ({ userId, workspaceId }) => {
const role = await getWorkspaceRoleForUserFactory({ db })({
userId,
workspaceId
})
if (!role) return err(new Authz.WorkspaceRoleNotFoundError())
return ok(role.role)
return role?.role || null
},
getWorkspaceSsoSession: async ({ userId, workspaceId }) => {
const ssoSession = await getUserSsoSessionFactory({ db })({
userId,
workspaceId
})
if (!ssoSession) return err(new Authz.WorkspaceSsoSessionNotFoundError())
return ok(ssoSession)
return ssoSession || null
},
getWorkspaceSsoProvider: async ({ workspaceId }) => {
const ssoProvider = await getWorkspaceSsoProviderRecordFactory({ db })({
workspaceId
})
if (!ssoProvider) return err(new Authz.WorkspaceSsoProviderNotFoundError())
return ok(ssoProvider)
return ssoProvider || null
},
getWorkspaceSeat: async ({ userId, workspaceId }, { dataLoaders }) => {
return (
(
await dataLoaders.gatekeeper!.getUserWorkspaceSeat.load({
userId,
workspaceId
})
)?.type || null
)
},
getWorkspaceProjectCount: async ({ workspaceId }, { dataLoaders }) => {
return await dataLoaders.workspaces!.getProjectCount.load(workspaceId)
},
getWorkspacePlan: async ({ workspaceId }) => {
return await getWorkspacePlan({ workspaceId })
},
getWorkspaceLimits: async ({ workspaceId }) => {
const plan = await getWorkspacePlan({ workspaceId })
if (!plan) return null
const config = { ...WorkspacePaidPlanConfigs, ...WorkspaceUnpaidPlanConfigs }
return config[plan.name]?.limits ?? null
}
}
})
@@ -234,6 +234,12 @@ export type QueryAllWorkspaceProjects = (
args: QueryAllWorkspaceProjectsArgs
) => AsyncGenerator<StreamWithOptionalRole[], void, unknown>
export type GetWorkspacesProjectsCounts = (params: {
workspaceIds: string[]
}) => Promise<{
[workspaceId: string]: number
}>
/** Workspace Project Roles */
type GrantWorkspaceProjectRolesArgs = {
@@ -1,13 +1,7 @@
import { UserEmail } from '@/modules/core/domain/userEmails/types'
import { User } from '@/modules/core/domain/users/types'
import { BaseError } from '@/modules/shared/errors/base'
export class SsoSessionMissingOrExpiredError extends BaseError {
static defaultMessage =
'No valid SSO session found for the given workspace. Please sign in.'
static code = 'SSO_SESSION_MISSING_OR_EXPIRED_ERROR'
static statusCode = 401
}
export { SsoSessionMissingOrExpiredError } from '@/modules/workspacesCore/errors'
export class SsoVerificationCodeMissingError extends BaseError {
static defaultMessage = 'Cannot find verification token. Restart authentication flow.'
@@ -100,7 +100,9 @@ import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regio
import {
getWorkspacePlanFactory,
getWorkspaceSubscriptionFactory,
getWorkspaceWithPlanFactory
getWorkspaceWithPlanFactory,
upsertTrialWorkspacePlanFactory,
upsertUnpaidWorkspacePlanFactory
} from '@/modules/gatekeeper/repositories/billing'
import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat'
import {
@@ -119,6 +121,9 @@ import {
import { getUserFactory } from '@/modules/core/repositories/users'
import { authorizeResolver } from '@/modules/shared'
import { isNewPaidPlanType, isNewPlanType } from '@/modules/gatekeeper/helpers/plans'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
const { FF_GATEKEEPER_FORCE_FREE_PLAN } = getFeatureFlags()
export const onProjectCreatedFactory =
(deps: {
@@ -235,7 +240,11 @@ export const onWorkspaceAuthorizedFactory =
const session = await getUserSsoSession({ userId, workspaceId })
if (!session || !isValidSsoSession(session)) {
const workspace = await getWorkspace({ workspaceId })
throw new SsoSessionMissingOrExpiredError(workspace?.slug)
throw new SsoSessionMissingOrExpiredError(workspace?.slug, {
info: {
workspaceSlug: workspace?.slug
}
})
}
}
@@ -732,6 +741,28 @@ export const initializeEventListenersFactory =
})
await onWorkspaceAuthorized(payload)
}),
eventBus.listen(WorkspaceEvents.Created, async ({ payload }) => {
// TODO: based on a feature flag, we can force new workspaces into the free plan here
if (FF_GATEKEEPER_FORCE_FREE_PLAN) {
await upsertUnpaidWorkspacePlanFactory({ db })({
workspacePlan: {
name: 'free',
status: 'valid',
workspaceId: payload.workspace.id,
createdAt: new Date()
}
})
} else {
await upsertTrialWorkspacePlanFactory({ db })({
workspacePlan: {
name: 'starter',
status: 'trial',
workspaceId: payload.workspace.id,
createdAt: new Date()
}
})
}
}),
eventBus.listen(WorkspaceEvents.RoleDeleted, async ({ payload }) => {
const trx = await db.transaction()
const onWorkspaceRoleDeleted = onWorkspaceRoleDeletedFactory({
@@ -2,7 +2,8 @@ import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { defineRequestDataloaders } from '@/modules/shared/helpers/graphqlHelper'
import {
getWorkspaceDomainsFactory,
getWorkspacesFactory
getWorkspacesFactory,
getWorkspacesProjectsCountsFactory
} from '@/modules/workspaces/repositories/workspaces'
import {
WorkspaceDomain,
@@ -21,6 +22,7 @@ const dataLoadersDefinition = defineRequestDataloaders(
({ ctx, createLoader, deps: { db } }) => {
const getWorkspaces = getWorkspacesFactory({ db })
const getWorkspaceDomains = getWorkspaceDomainsFactory({ db })
const getWorkspacesProjectsCounts = getWorkspacesProjectsCountsFactory({ db })
return {
workspaces: {
@@ -35,7 +37,16 @@ const dataLoadersDefinition = defineRequestDataloaders(
)
return ids.map((id) => results[id] || null)
}
)
),
/**
* Get workspace project count
*/
getProjectCount: createLoader<string, number | null>(async (ids) => {
const results = await getWorkspacesProjectsCounts({
workspaceIds: ids.slice()
})
return ids.map((id) => results[id] ?? null)
})
},
workspaceDomains: {
/**
@@ -0,0 +1,19 @@
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { Authz } from '@speckle/shared'
export default {
Workspace: {
permissions: (parent) => ({
workspaceId: parent.id
})
},
WorkspacePermissionChecks: {
canCreateProject: async (parent, _args, ctx) => {
const canCreateProject = await ctx.authPolicies.workspace.canCreateProject({
workspaceId: parent.workspaceId,
userId: ctx.userId
})
return Authz.toGraphqlResult(canCreateProject)
}
}
} as Resolvers
@@ -111,6 +111,7 @@ import {
getWorkspacesForUserFactory
} from '@/modules/workspaces/services/retrieval'
import {
Authz,
Roles,
WorkspaceRoles,
removeNullOrUndefinedKeys,
@@ -183,7 +184,7 @@ import {
} from '@/modules/gatekeeper/repositories/billing'
import { Knex } from 'knex'
import { getPaginatedItemsFactory } from '@/modules/shared/services/paginatedItems'
import { BadRequestError } from '@/modules/shared/errors'
import { BadRequestError, ForbiddenError } from '@/modules/shared/errors'
import {
dismissWorkspaceJoinRequestFactory,
requestToJoinWorkspaceFactory
@@ -958,13 +959,27 @@ export = FF_WORKSPACES_MODULE_ENABLED
throw new RateLimitError(rateLimitResult)
}
await authorizeResolver(
context.userId!,
args.input.workspaceId,
Roles.Workspace.Member,
context.resourceAccessRules,
OperationTypeNode.MUTATION
)
const canCreate = await context.authPolicies.workspace.canCreateProject({
userId: context.userId,
workspaceId: args.input.workspaceId
})
if (!canCreate.isOk) {
switch (canCreate.error.code) {
case Authz.WorkspacesNotEnabledError.code:
case Authz.WorkspaceNoAccessError.code:
case Authz.WorkspaceReadOnlyError.code:
case Authz.WorkspaceNoEditorSeatError.code:
case Authz.WorkspaceNotEnoughPermissionsError.code:
case Authz.WorkspaceSsoSessionNoAccessError.code:
case Authz.WorkspaceLimitsReachedError.code:
case Authz.ServerNoSessionError.code:
case Authz.ServerNoAccessError.code:
throw new ForbiddenError(canCreate.error.message)
default:
throwUncoveredError(canCreate.error)
}
}
const createWorkspaceProject = createWorkspaceProjectFactory({
getDefaultRegion: getDefaultRegionFactory({ db })
@@ -26,6 +26,7 @@ import {
GetWorkspaceRolesForUser,
GetWorkspaceWithDomains,
GetWorkspaces,
GetWorkspacesProjectsCounts,
QueryWorkspaces,
StoreWorkspaceDomain,
UpsertWorkspace,
@@ -508,3 +509,31 @@ export const upsertWorkspaceCreationStateFactory =
.onConflict('workspaceId')
.merge()
}
export const getWorkspacesProjectsCountsFactory =
(deps: { db: Knex }): GetWorkspacesProjectsCounts =>
async (params) => {
const ret = params.workspaceIds.reduce((acc, workspaceId) => {
acc[workspaceId] = 0
return acc
}, {} as Record<string, number>)
const q = tables
.streams(deps.db)
.select<
{
workspaceId: string
count: string
}[]
>([Streams.col.workspaceId, knex.raw('count(*) as count')])
.whereIn(Streams.col.workspaceId, params.workspaceIds)
.groupBy(Streams.col.workspaceId)
const res = await q
for (const { workspaceId, count } of res) {
ret[workspaceId] = parseInt(count)
}
return ret
}
@@ -38,7 +38,12 @@ import {
import { BasicTestUser } from '@/test/authHelper'
import { CreateWorkspaceInviteMutationVariables } from '@/test/graphql/generated/graphql'
import cryptoRandomString from 'crypto-random-string'
import { MaybeNullOrUndefined, Roles, WorkspaceRoles } from '@speckle/shared'
import {
MaybeNullOrUndefined,
Roles,
WorkspacePlan,
WorkspaceRoles
} from '@speckle/shared'
import { getStreamFactory } from '@/modules/core/repositories/streams'
import { getUserFactory } from '@/modules/core/repositories/users'
import { getServerInfoFactory } from '@/modules/core/repositories/server'
@@ -70,7 +75,6 @@ import {
upsertRegionAssignmentFactory
} from '@/modules/workspaces/repositories/regions'
import { getDb } from '@/modules/multiregion/utils/dbSelector'
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
import {
assignWorkspaceSeatFactory,
@@ -13,5 +13,17 @@ export default defineModuleLoaders(() => ({
},
getWorkspaceSsoProvider: async () => {
throw new LoaderUnsupportedError()
},
getWorkspaceSeat: async () => {
throw new LoaderUnsupportedError()
},
getWorkspaceProjectCount: async () => {
throw new LoaderUnsupportedError()
},
getWorkspacePlan: async () => {
throw new LoaderUnsupportedError()
},
getWorkspaceLimits: async () => {
throw new LoaderUnsupportedError()
}
}))
@@ -0,0 +1,10 @@
import { BaseError } from '@/modules/shared/errors/base'
export class SsoSessionMissingOrExpiredError extends BaseError<{
workspaceSlug: string
}> {
static defaultMessage =
'No valid SSO session found for the given workspace. Please sign in.'
static code = 'SSO_SESSION_MISSING_OR_EXPIRED_ERROR'
static statusCode = 401
}
@@ -36,3 +36,7 @@ export type PendingWorkspaceCollaboratorGraphQLReturn = {
}
export type WorkspaceCollaboratorGraphQLReturn = WorkspaceTeamMember
export type WorkspacePermissionChecksGraphQLReturn = {
workspaceId: string
}
@@ -1965,6 +1965,14 @@ export type PendingWorkspaceCollaboratorsFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
export type PermissionCheckResult = {
__typename?: 'PermissionCheckResult';
authorized: Scalars['Boolean']['output'];
code: Scalars['String']['output'];
message: Scalars['String']['output'];
payload?: Maybe<Scalars['JSONObject']['output']>;
};
export type Price = {
__typename?: 'Price';
amount: Scalars['Float']['output'];
@@ -2010,6 +2018,7 @@ export type Project = {
pendingAccessRequests?: Maybe<Array<ProjectAccessRequest>>;
/** Returns a list models that are being created from a file import */
pendingImportedModels: Array<FileUpload>;
permissions: ProjectPermissionChecks;
/** Active user's role for this project. `null` if request is not authenticated, or the project is not explicitly shared with you. */
role?: Maybe<Scalars['String']['output']>;
/** Source apps used in any models of this project */
@@ -2510,6 +2519,11 @@ export const ProjectPendingVersionsUpdatedMessageType = {
} as const;
export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVersionsUpdatedMessageType[keyof typeof ProjectPendingVersionsUpdatedMessageType];
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canRead: PermissionCheckResult;
};
export type ProjectRole = {
__typename?: 'ProjectRole';
project: Project;
@@ -4340,6 +4354,7 @@ export type Workspace = {
logo?: Maybe<Scalars['String']['output']>;
membersByRole?: Maybe<WorkspaceMembersByRole>;
name: Scalars['String']['output'];
permissions: WorkspacePermissionChecks;
plan?: Maybe<WorkspacePlan>;
projects: ProjectCollection;
/** A Workspace is marked as readOnly if its trial period is finished or a paid plan is subscribed but payment has failed */
@@ -4704,6 +4719,11 @@ export const WorkspacePaymentMethod = {
} as const;
export type WorkspacePaymentMethod = typeof WorkspacePaymentMethod[keyof typeof WorkspacePaymentMethod];
export type WorkspacePermissionChecks = {
__typename?: 'WorkspacePermissionChecks';
canCreateProject: PermissionCheckResult;
};
export type WorkspacePlan = {
__typename?: 'WorkspacePlan';
createdAt: Scalars['DateTime']['output'];
@@ -2,33 +2,13 @@ import { describe, expect, it } from 'vitest'
import { isPubliclyReadableProject, hasMinimumProjectRole } from './projects.js'
import cryptoRandomString from 'crypto-random-string'
import { Roles } from '../../core/index.js'
import { err, ok } from 'true-myth/result'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ProjectRoleNotFoundError,
WorkspaceSsoSessionNoAccessError
} from '../domain/authErrors.js'
import { getProjectFake } from '../../tests/fakes.js'
describe('project checks', () => {
describe('isPubliclyReadableProject returns a function, that', () => {
it('throws uncoveredError for unexpected loader errors', async () => {
await expect(
isPubliclyReadableProject({
// @ts-expect-error deliberately testing an unexpeceted error type
getProject: async () => err(new ProjectRoleNotFoundError())
})({ projectId: cryptoRandomString({ length: 10 }) })
).rejects.toThrowError(/Uncovered error/)
})
it.each([
ProjectNotFoundError,
ProjectNoAccessError,
WorkspaceSsoSessionNoAccessError
])('turns expected loader error $code into false ', async (loaderError) => {
it('turns not found project into false ', async () => {
const result = await isPubliclyReadableProject({
getProject: async () =>
err(new loaderError({ payload: { workspaceSlug: 'foo' } }))
getProject: async () => null
})({ projectId: cryptoRandomString({ length: 10 }) })
expect(result).toEqual(false)
})
@@ -46,31 +26,20 @@ describe('project checks', () => {
})
})
describe('hasMinimumProjectRole returns a function, that', () => {
it('throws uncoveredError for unexpected loader errors', async () => {
it('returns false for not existing project roles', async () => {
await expect(
hasMinimumProjectRole({
// @ts-expect-error deliberately testing an unexpeceted error type
getProjectRole: async () => err(new ProjectNotFoundError())
getProjectRole: async () => null
})({
projectId: cryptoRandomString({ length: 10 }),
userId: cryptoRandomString({ length: 10 }),
role: Roles.Stream.Contributor
})
).rejects.toThrowError(/Uncovered error/)
})
it('returns false, if there is no role for the user', async () => {
const result = await hasMinimumProjectRole({
getProjectRole: () => Promise.resolve(err(new ProjectRoleNotFoundError()))
})({
projectId: cryptoRandomString({ length: 10 }),
userId: cryptoRandomString({ length: 10 }),
role: Roles.Stream.Contributor
})
expect(result).toEqual(false)
).resolves.toStrictEqual(false)
})
it('returns false, if the role is not sufficient', async () => {
const result = await hasMinimumProjectRole({
getProjectRole: () => Promise.resolve(ok(Roles.Stream.Reviewer))
getProjectRole: async () => Roles.Stream.Reviewer
})({
projectId: cryptoRandomString({ length: 10 }),
userId: cryptoRandomString({ length: 10 }),
@@ -80,7 +49,7 @@ describe('project checks', () => {
})
it('returns true, if the role is sufficient', async () => {
const result = await hasMinimumProjectRole({
getProjectRole: () => Promise.resolve(ok(Roles.Stream.Contributor))
getProjectRole: async () => Roles.Stream.Contributor
})({
projectId: cryptoRandomString({ length: 10 }),
userId: cryptoRandomString({ length: 10 }),
+7 -24
View File
@@ -1,6 +1,7 @@
import { StreamRoles, throwUncoveredError } from '../../core/index.js'
import { AuthPolicyCheck, ProjectContext, UserContext } from '../domain/policies.js'
import { StreamRoles } from '../../core/index.js'
import { AuthPolicyCheck } from '../domain/policies.js'
import { isMinimumProjectRole } from '../domain/logic/roles.js'
import { ProjectContext, UserContext } from '../domain/context.js'
export const hasMinimumProjectRole: AuthPolicyCheck<
'getProjectRole',
@@ -9,32 +10,14 @@ export const hasMinimumProjectRole: AuthPolicyCheck<
(loaders) =>
async ({ userId, projectId, role: requiredProjectRole }) => {
const userProjectRole = await loaders.getProjectRole({ userId, projectId })
if (userProjectRole.isErr) {
switch (userProjectRole.error.code) {
case 'ProjectRoleNotFound':
return false
default:
throwUncoveredError(userProjectRole.error.code)
}
}
return isMinimumProjectRole(userProjectRole.value, requiredProjectRole)
if (!userProjectRole) return false
return isMinimumProjectRole(userProjectRole, requiredProjectRole)
}
export const isPubliclyReadableProject: AuthPolicyCheck<'getProject', ProjectContext> =
(loaders) =>
async ({ projectId }) => {
const project = await loaders.getProject({ projectId })
if (project.isErr) {
switch (project.error.code) {
case 'ProjectNotFound':
return false
case 'ProjectNoAccess':
return false
case 'WorkspaceSsoSessionNoAccess':
return false
default:
throwUncoveredError(project.error)
}
}
return project.value.isPublic || project.value.isDiscoverable
if (!project) return false
return project.isPublic || project.isDiscoverable
}
@@ -1,34 +1,18 @@
import { describe, expect, it } from 'vitest'
import { hasMinimumServerRole, canUseAdminOverride } from './serverRole.js'
import cryptoRandomString from 'crypto-random-string'
import { err, ok } from 'true-myth/result'
import {
ServerRoleNotFoundError,
ProjectRoleNotFoundError
} from '../domain/authErrors.js'
import { parseFeatureFlags } from '../../environment/index.js'
describe('hasMinimumServerRole returns a function, that', () => {
it('throws uncoveredError for unexpected loader errors', async () => {
await expect(
hasMinimumServerRole({
// @ts-expect-error deliberately testing an unexpected loader error
getServerRole: async () => err(new ProjectRoleNotFoundError())
})({ userId: cryptoRandomString({ length: 10 }), role: 'server:user' })
).rejects.toThrowError(/Uncovered error/)
it('turns non existing server roles into false ', async () => {
const result = await hasMinimumServerRole({
getServerRole: async () => null
})({ userId: cryptoRandomString({ length: 10 }), role: 'server:user' })
expect(result).toEqual(false)
})
it.each([ServerRoleNotFoundError])(
'turns expected loader error $code into false ',
async (loaderError) => {
const result = await hasMinimumServerRole({
getServerRole: async () => err(new loaderError())
})({ userId: cryptoRandomString({ length: 10 }), role: 'server:user' })
expect(result).toEqual(false)
}
)
it('returns false for smaller roles', async () => {
const result = await hasMinimumServerRole({
getServerRole: async () => Promise.resolve(ok('server:user'))
getServerRole: async () => 'server:user'
})({
userId: cryptoRandomString({ length: 9 }),
role: 'server:admin'
@@ -37,7 +21,7 @@ describe('hasMinimumServerRole returns a function, that', () => {
})
it('returns true for roles with enough power', async () => {
const result = await hasMinimumServerRole({
getServerRole: () => Promise.resolve(ok('server:admin'))
getServerRole: async () => 'server:admin'
})({
userId: cryptoRandomString({ length: 9 }),
role: 'server:guest'
@@ -59,21 +43,21 @@ describe('canUseAdminOverride returns a function, that', () => {
it('returns false for non admins if admin override is not enabled', async () => {
const result = await canUseAdminOverride({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => ok('server:user')
getServerRole: async () => 'server:user'
})({ userId: cryptoRandomString({ length: 10 }) })
expect(result).toEqual(false)
})
it('returns false for non admins if admin override is enabled', async () => {
const result = await canUseAdminOverride({
getEnv: async () => parseFeatureFlags({ FF_ADMIN_OVERRIDE_ENABLED: 'true' }),
getServerRole: async () => ok('server:user')
getServerRole: async () => 'server:user'
})({ userId: cryptoRandomString({ length: 10 }) })
expect(result).toEqual(false)
})
it('returns true for admins if admin override is enabled', async () => {
const result = await canUseAdminOverride({
getEnv: async () => parseFeatureFlags({ FF_ADMIN_OVERRIDE_ENABLED: 'true' }),
getServerRole: async () => ok('server:admin')
getServerRole: async () => 'server:admin'
})({ userId: cryptoRandomString({ length: 10 }) })
expect(result).toEqual(true)
})
+5 -12
View File
@@ -1,29 +1,22 @@
import { Roles, ServerRoles } from '../../core/constants.js'
import { throwUncoveredError } from '../../core/index.js'
import { UserContext } from '../domain/context.js'
import { isMinimumServerRole } from '../domain/logic/roles.js'
import { AuthPolicyCheck } from '../domain/policies.js'
export const hasMinimumServerRole: AuthPolicyCheck<
'getServerRole',
{ userId: string; role: ServerRoles }
UserContext & { role: ServerRoles }
> =
(loaders) =>
async ({ userId, role: requiredServerRole }) => {
const userServerRole = await loaders.getServerRole({ userId })
if (userServerRole.isErr) {
switch (userServerRole.error.code) {
case 'ServerRoleNotFound':
return false
default:
throwUncoveredError(userServerRole.error.code)
}
}
return isMinimumServerRole(userServerRole.value, requiredServerRole)
if (!userServerRole) return false
return isMinimumServerRole(userServerRole, requiredServerRole)
}
export const canUseAdminOverride: AuthPolicyCheck<
'getEnv' | 'getServerRole',
{ userId: string }
UserContext
> =
(loaders) =>
async ({ userId }) => {
@@ -1,39 +1,11 @@
import { describe, expect, it } from 'vitest'
import { hasAnyWorkspaceRole, requireMinimumWorkspaceRole } from './workspaceRole.js'
import cryptoRandomString from 'crypto-random-string'
import { err, ok } from 'true-myth/result'
import {
WorkspaceRoleNotFoundError,
ProjectRoleNotFoundError
} from '../domain/authErrors.js'
describe('hasAnyWorkspaceRole returns a function, that', () => {
it('throws uncoveredError for unexpected loader errors', async () => {
await expect(
hasAnyWorkspaceRole({
// @ts-expect-error deliberately testing an unexpected loader error
getWorkspaceRole: async () => err(new ProjectRoleNotFoundError())
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
).rejects.toThrowError(/Uncovered error/)
})
it.each([WorkspaceRoleNotFoundError])(
'turns expected loader error $code into false ',
async (loaderError) => {
const result = await hasAnyWorkspaceRole({
getWorkspaceRole: async () => err(new loaderError())
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
expect(result).toEqual(false)
}
)
it('returns false if the user has no role', async () => {
const result = await hasAnyWorkspaceRole({
getWorkspaceRole: async () => err(new WorkspaceRoleNotFoundError())
getWorkspaceRole: async () => null
})({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 })
@@ -42,7 +14,7 @@ describe('hasAnyWorkspaceRole returns a function, that', () => {
})
it('returns true if the user has a role', async () => {
const result = await hasAnyWorkspaceRole({
getWorkspaceRole: async () => ok('workspace:member')
getWorkspaceRole: async () => 'workspace:member'
})({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 })
@@ -52,34 +24,19 @@ describe('hasAnyWorkspaceRole returns a function, that', () => {
})
describe('requireMinimumWorkspaceRole returns a function, that', () => {
it('throws uncoveredError for unexpected loader errors', async () => {
await expect(
requireMinimumWorkspaceRole({
// @ts-expect-error deliberately testing an unexpected loader error
getWorkspaceRole: async () => err(new ProjectRoleNotFoundError())
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 }),
role: 'workspace:member'
})
).rejects.toThrowError(/Uncovered error/)
it('turns non existing workspace role into false ', async () => {
const result = await requireMinimumWorkspaceRole({
getWorkspaceRole: async () => null
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 }),
role: 'workspace:member'
})
expect(result).toEqual(false)
})
it.each([WorkspaceRoleNotFoundError])(
'turns expected loader error $code into false ',
async (loaderError) => {
const result = await requireMinimumWorkspaceRole({
getWorkspaceRole: async () => err(new loaderError())
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 }),
role: 'workspace:member'
})
expect(result).toEqual(false)
}
)
it('returns false if user is below target role', async () => {
const result = await requireMinimumWorkspaceRole({
getWorkspaceRole: () => Promise.resolve(ok('workspace:member'))
getWorkspaceRole: async () => 'workspace:member'
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 }),
@@ -89,7 +46,7 @@ describe('requireMinimumWorkspaceRole returns a function, that', () => {
})
it('returns true if user matches target role', async () => {
const result = await requireMinimumWorkspaceRole({
getWorkspaceRole: () => Promise.resolve(ok('workspace:member'))
getWorkspaceRole: async () => 'workspace:member'
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 }),
@@ -99,7 +56,7 @@ describe('requireMinimumWorkspaceRole returns a function, that', () => {
})
it('returns true if user exceeds target role', async () => {
const result = await requireMinimumWorkspaceRole({
getWorkspaceRole: () => Promise.resolve(ok('workspace:admin'))
getWorkspaceRole: async () => 'workspace:admin'
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 }),
@@ -1,25 +1,18 @@
import { WorkspaceRoles } from '../../core/constants.js'
import { throwUncoveredError } from '../../core/index.js'
import { UserContext, WorkspaceContext } from '../domain/context.js'
import { isMinimumWorkspaceRole } from '../domain/logic/roles.js'
import { AuthPolicyCheck } from '../domain/policies.js'
export const requireMinimumWorkspaceRole: AuthPolicyCheck<
'getWorkspaceRole',
{ userId: string; workspaceId: string; role: WorkspaceRoles }
UserContext & WorkspaceContext & { role: WorkspaceRoles }
> =
(loaders) =>
async ({ userId, workspaceId, role: requiredWorkspaceRole }) => {
const userWorkspaceRole = await loaders.getWorkspaceRole({ userId, workspaceId })
if (userWorkspaceRole.isErr) {
switch (userWorkspaceRole.error.code) {
case 'WorkspaceRoleNotFound':
return false
default:
throwUncoveredError(userWorkspaceRole.error.code)
}
}
if (!userWorkspaceRole) return false
return isMinimumWorkspaceRole(userWorkspaceRole.value, requiredWorkspaceRole)
return isMinimumWorkspaceRole(userWorkspaceRole, requiredWorkspaceRole)
}
export const hasAnyWorkspaceRole: AuthPolicyCheck<
@@ -29,11 +22,5 @@ export const hasAnyWorkspaceRole: AuthPolicyCheck<
(loaders) =>
async ({ userId, workspaceId }) => {
const userWorkspaceRole = await loaders.getWorkspaceRole({ userId, workspaceId })
if (userWorkspaceRole.isOk) return true
switch (userWorkspaceRole.error.code) {
case 'WorkspaceRoleNotFound':
return false
default:
throwUncoveredError(userWorkspaceRole.error.code)
}
return userWorkspaceRole !== null
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { hasEditorSeat } from './workspaceSeat.js'
import { nanoid } from 'nanoid'
describe('hasEditorSeat returns a function, that', () => {
it('returns false when user has no seats', async () => {
const result = hasEditorSeat({ getWorkspaceSeat: async () => null })({
userId: nanoid(10),
workspaceId: nanoid(10)
})
await expect(result).resolves.toBe(false)
})
it('returns false when user has non editor seats', async () => {
const result = hasEditorSeat({ getWorkspaceSeat: async () => 'viewer' })({
userId: nanoid(10),
workspaceId: nanoid(10)
})
await expect(result).resolves.toBe(false)
})
it('returns true when user has editor seats', async () => {
const result = hasEditorSeat({ getWorkspaceSeat: async () => 'editor' })({
userId: nanoid(10),
workspaceId: nanoid(10)
})
await expect(result).resolves.toBe(true)
})
})
@@ -0,0 +1,17 @@
import { SeatTypes } from '../../core/constants.js'
import { UserContext, WorkspaceContext } from '../domain/context.js'
import { AuthPolicyCheck } from '../domain/policies.js'
export const hasEditorSeat: AuthPolicyCheck<
'getWorkspaceSeat',
UserContext & WorkspaceContext
> =
(loaders) =>
async ({ userId, workspaceId }) => {
const seat = await loaders.getWorkspaceSeat({
userId,
workspaceId
})
if (!seat) return false
return seat === SeatTypes.Editor
}
+22 -22
View File
@@ -1,3 +1,5 @@
import { WorkspaceLimits } from '../../workspaces/helpers/limits.js'
export type AuthError<ErrorCode extends string = string, Payload = undefined> = {
readonly code: ErrorCode
readonly message: string
@@ -50,14 +52,9 @@ export const ProjectNoAccessError = defineAuthError({
message: 'You do not have access to the project'
})
export const ProjectRoleNotFoundError = defineAuthError({
code: 'ProjectRoleNotFound',
message: 'Could not resolve your project role'
})
export const WorkspaceNotFoundError = defineAuthError({
code: 'WorkspaceNotFound',
message: 'Workspace not found'
export const WorkspacesNotEnabledError = defineAuthError({
code: 'WorkspacesNotEnabled',
message: 'This server does not support workspaces'
})
export const WorkspaceNoAccessError = defineAuthError({
@@ -65,14 +62,22 @@ export const WorkspaceNoAccessError = defineAuthError({
message: 'You do not have access to the workspace'
})
export const WorkspaceSsoProviderNotFoundError = defineAuthError({
code: 'WorkspaceSsoProviderNotFound',
message: 'The workspace SSO provider was not found'
export const WorkspaceNotEnoughPermissionsError = defineAuthError({
code: 'WorkspaceNotEnoughPermissions',
message: 'You do not have enough permissions in the workspace to perform this action'
})
export const WorkspaceSsoSessionNotFoundError = defineAuthError({
code: 'WorkspaceSsoSessionNotFound',
message: 'Your workspace SSO session was not found'
export const WorkspaceReadOnlyError = defineAuthError({
code: 'WorkspaceReadOnly',
message: 'The workspace is in a read only mode, upgrade your plan to unlock it'
})
export const WorkspaceLimitsReachedError = defineAuthError<
'WorkspaceLimitsReached',
{ limit: keyof WorkspaceLimits }
>({
code: 'WorkspaceLimitsReached',
message: 'Workspace limits have been reached'
})
export const WorkspaceSsoSessionNoAccessError = defineAuthError<
@@ -85,9 +90,9 @@ export const WorkspaceSsoSessionNoAccessError = defineAuthError<
message: 'Your workspace SSO session is expired or it does not exist'
})
export const WorkspaceRoleNotFoundError = defineAuthError({
code: 'WorkspaceRoleNotFound',
message: 'The user does not have a role in the workspace'
export const WorkspaceNoEditorSeatError = defineAuthError({
code: 'WorkspaceNoEditorSeat',
message: 'You need an editor seat to perform this action'
})
export const ServerNoAccessError = defineAuthError({
@@ -99,8 +104,3 @@ export const ServerNoSessionError = defineAuthError({
code: 'ServerNoSession',
message: 'You are not logged in to this server'
})
export const ServerRoleNotFoundError = defineAuthError({
code: 'ServerRoleNotFound',
message: 'Could not resolve your server role'
})
@@ -0,0 +1,4 @@
export type ProjectContext = { projectId: string }
export type UserContext = { userId: string }
export type MaybeUserContext = { userId?: string }
export type WorkspaceContext = { workspaceId: string }
@@ -1,7 +1,3 @@
import Result from 'true-myth/result'
import { ServerRoles } from '../../../core/constants.js'
import { ServerRoleNotFoundError } from '../authErrors.js'
export type GetServerRole = (args: {
userId: string
}) => Promise<Result<ServerRoles, InstanceType<typeof ServerRoleNotFoundError>>>
export type GetServerRole = (args: { userId: string }) => Promise<ServerRoles | null>
@@ -5,7 +5,11 @@ import type { GetProject, GetProjectRole } from './projects/operations.js'
import type {
GetEnv,
GetWorkspace,
GetWorkspaceLimits,
GetWorkspacePlan,
GetWorkspaceProjectCount,
GetWorkspaceRole,
GetWorkspaceSeat,
GetWorkspaceSsoProvider,
GetWorkspaceSsoSession
} from './workspaces/operations.js'
@@ -45,6 +49,10 @@ export const AuthCheckContextLoaderKeys = <const>{
getServerRole: 'getServerRole',
getWorkspace: 'getWorkspace',
getWorkspaceRole: 'getWorkspaceRole',
getWorkspaceSeat: 'getWorkspaceSeat',
getWorkspaceProjectCount: 'getWorkspaceProjectCount',
getWorkspacePlan: 'getWorkspacePlan',
getWorkspaceLimits: 'getWorkspaceLimits',
getWorkspaceSsoProvider: 'getWorkspaceSsoProvider',
getWorkspaceSsoSession: 'getWorkspaceSsoSession'
}
@@ -60,6 +68,10 @@ export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{
getServerRole: GetServerRole
getWorkspace: GetWorkspace
getWorkspaceRole: GetWorkspaceRole
getWorkspaceLimits: GetWorkspaceLimits
getWorkspacePlan: GetWorkspacePlan
getWorkspaceSeat: GetWorkspaceSeat
getWorkspaceProjectCount: GetWorkspaceProjectCount
getWorkspaceSsoProvider: GetWorkspaceSsoProvider
getWorkspaceSsoSession: GetWorkspaceSsoSession
}>
@@ -4,10 +4,6 @@ import { AuthError } from './authErrors.js'
import { AuthCheckContextLoaderKeys, AuthCheckContextLoaders } from './loaders.js'
import Maybe from 'true-myth/maybe'
export type ProjectContext = { projectId: string }
export type UserContext = { userId: string }
export type MaybeUserContext = { userId?: string }
// a complete policy always returns a full result
export type AuthPolicy<
LoaderKeys extends AuthCheckContextLoaderKeys,
@@ -1,25 +1,9 @@
import { Result } from 'true-myth/result'
import { StreamRoles } from '../../../core/constants.js'
import { Project } from './types.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ProjectRoleNotFoundError,
WorkspaceSsoSessionNoAccessError
} from '../authErrors.js'
export type GetProject = (args: {
projectId: string
}) => Promise<
Result<
Project,
| InstanceType<typeof ProjectNotFoundError>
| InstanceType<typeof ProjectNoAccessError>
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
>
>
export type GetProject = (args: { projectId: string }) => Promise<Project | null>
export type GetProjectRole = (args: {
userId: string
projectId: string
}) => Promise<Result<StreamRoles, InstanceType<typeof ProjectRoleNotFoundError>>>
}) => Promise<StreamRoles | null>
@@ -1,43 +1,36 @@
import Result from 'true-myth/result'
import { WorkspaceRoles } from '../../../core/constants.js'
import { WorkspaceRoles, WorkspaceSeatType } from '../../../core/constants.js'
import { FeatureFlags } from '../../../environment/index.js'
import { WorkspaceLimits } from '../../../workspaces/helpers/limits.js'
import { WorkspacePlan } from '../../../workspaces/index.js'
import { UserContext, WorkspaceContext } from '../context.js'
import { Workspace, WorkspaceSsoProvider, WorkspaceSsoSession } from './types.js'
import {
WorkspaceNoAccessError,
WorkspaceNotFoundError,
WorkspaceRoleNotFoundError,
WorkspaceSsoProviderNotFoundError,
WorkspaceSsoSessionNoAccessError,
WorkspaceSsoSessionNotFoundError
} from '../authErrors.js'
export type GetWorkspace = (args: {
workspaceId: string
}) => Promise<
Result<
Workspace,
| InstanceType<typeof WorkspaceNotFoundError>
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
>
>
export type GetWorkspace = (args: WorkspaceContext) => Promise<Workspace | null>
export type GetWorkspaceRole = (args: {
userId: string
workspaceId: string
}) => Promise<Result<WorkspaceRoles, InstanceType<typeof WorkspaceRoleNotFoundError>>>
export type GetWorkspaceRole = (
args: UserContext & WorkspaceContext
) => Promise<WorkspaceRoles | null>
export type GetWorkspaceSsoProvider = (args: {
workspaceId: string
}) => Promise<
Result<WorkspaceSsoProvider, InstanceType<typeof WorkspaceSsoProviderNotFoundError>>
>
export type GetWorkspaceLimits = (
args: WorkspaceContext
) => Promise<WorkspaceLimits | null>
export type GetWorkspaceSsoSession = (args: {
userId: string
workspaceId: string
}) => Promise<
Result<WorkspaceSsoSession, InstanceType<typeof WorkspaceSsoSessionNotFoundError>>
>
export type GetWorkspacePlan = (args: WorkspaceContext) => Promise<WorkspacePlan | null>
export type GetWorkspaceProjectCount = (
args: WorkspaceContext
) => Promise<number | null>
export type GetWorkspaceSeat = (
args: UserContext & WorkspaceContext
) => Promise<WorkspaceSeatType | null>
export type GetWorkspaceSsoProvider = (
args: WorkspaceContext
) => Promise<WorkspaceSsoProvider | null>
export type GetWorkspaceSsoSession = (
args: UserContext & WorkspaceContext
) => Promise<WorkspaceSsoSession | null>
export type GetEnv = () => Promise<FeatureFlags>
@@ -3,93 +3,62 @@ import { maybeMemberRoleWithValidSsoSessionIfNeeded } from './workspaceSso.js'
import cryptoRandomString from 'crypto-random-string'
import { err, ok } from 'true-myth/result'
import {
ProjectNotFoundError,
WorkspaceNoAccessError,
WorkspaceNotFoundError,
WorkspaceRoleNotFoundError,
WorkspaceSsoProviderNotFoundError,
WorkspaceSsoSessionNoAccessError,
WorkspaceSsoSessionNotFoundError
WorkspaceSsoSessionNoAccessError
} from '../domain/authErrors.js'
import { just, nothing } from 'true-myth/maybe'
describe('maybeMemberRoleWithValidSsoSessionIfNeeded returns a function, that', () => {
it.each([new WorkspaceNoAccessError(), new WorkspaceNotFoundError()])(
'remaps workspace loader error $code into WorkspaceNoAccessError',
async (expectedError) => {
const result = maybeMemberRoleWithValidSsoSessionIfNeeded({
getWorkspace: async () => err(expectedError),
getWorkspaceRole: async () => err(new WorkspaceRoleNotFoundError()),
getWorkspaceSsoProvider: async () =>
err(new WorkspaceSsoProviderNotFoundError()),
getWorkspaceSsoSession: async () => err(new WorkspaceSsoSessionNotFoundError())
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
await expect(result).resolves.toStrictEqual(
just(err(new WorkspaceNoAccessError()))
)
}
)
it('returns workspace loader error sso error completely', async () => {
const ssoError = new WorkspaceSsoSessionNoAccessError({
payload: { workspaceSlug: cryptoRandomString({ length: 10 }) }
})
it('hides non existing workspaces behind a WorkspaceNoAccessError', async () => {
const result = maybeMemberRoleWithValidSsoSessionIfNeeded({
getWorkspace: async () => err(ssoError),
getWorkspaceRole: async () => err(new WorkspaceRoleNotFoundError()),
getWorkspaceSsoProvider: async () => err(new WorkspaceSsoProviderNotFoundError()),
getWorkspaceSsoSession: async () => err(new WorkspaceSsoSessionNotFoundError())
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
await expect(result).resolves.toStrictEqual(just(err(ssoError)))
})
it('throws Uncovered error for unknown loader errors', async () => {
await expect(
maybeMemberRoleWithValidSsoSessionIfNeeded({
// @ts-expect-error testing the unexpected error case here
getWorkspace: async () => err(new WorkspaceSsoProviderNotFoundError()),
getWorkspaceRole: async () => err(new WorkspaceRoleNotFoundError()),
getWorkspaceSsoProvider: async () =>
err(new WorkspaceSsoProviderNotFoundError()),
getWorkspaceSsoSession: async () => err(new WorkspaceSsoSessionNotFoundError())
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
).rejects.toThrowError(/Uncovered error/)
})
it('returns nothing if user does not have a workspace role', async () => {
const result = await maybeMemberRoleWithValidSsoSessionIfNeeded({
getWorkspace: async () => {
return ok({
id: 'aaa',
slug: 'bbb'
})
getWorkspace: async () => null,
getWorkspaceRole: async () => {
expect.fail()
},
getWorkspaceRole: async () => err(new WorkspaceRoleNotFoundError()),
getWorkspaceSsoProvider: async () => err(new WorkspaceSsoProviderNotFoundError()),
getWorkspaceSsoSession: async () => err(new WorkspaceSsoSessionNotFoundError())
getWorkspaceSsoProvider: async () => {
expect.fail()
},
getWorkspaceSsoSession: async () => {
expect.fail()
}
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
expect(result).toStrictEqual(nothing())
await expect(result).resolves.toStrictEqual(just(err(new WorkspaceNoAccessError())))
})
it('returns WorkspaceNoAccessError if the user does not have a workspace role', async () => {
const result = await maybeMemberRoleWithValidSsoSessionIfNeeded({
getWorkspace: async () => ({
id: 'aaa',
slug: 'bbb'
}),
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => {
expect.fail()
},
getWorkspaceSsoSession: async () => {
expect.fail()
}
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
expect(result).toStrictEqual(just(err(new WorkspaceNoAccessError())))
})
it('returns nothing if user does not have a minimum workspace:member role', async () => {
const result = await maybeMemberRoleWithValidSsoSessionIfNeeded({
getWorkspace: async () => {
return ok({
id: 'aaa',
slug: 'bbb'
})
getWorkspace: async () => ({
id: 'aaa',
slug: 'bbb'
}),
getWorkspaceRole: async () => 'workspace:guest',
getWorkspaceSsoProvider: async () => {
expect.fail()
},
getWorkspaceRole: async () => ok('workspace:guest'),
getWorkspaceSsoProvider: async () => err(new WorkspaceSsoProviderNotFoundError()),
getWorkspaceSsoSession: async () => err(new WorkspaceSsoSessionNotFoundError())
getWorkspaceSsoSession: async () => {
expect.fail()
}
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
@@ -98,70 +67,32 @@ describe('maybeMemberRoleWithValidSsoSessionIfNeeded returns a function, that',
})
it('returns just(ok()) if user is a member and workspace has no SSO provider', async () => {
const result = await maybeMemberRoleWithValidSsoSessionIfNeeded({
getWorkspace: async () => {
return ok({
id: 'aaa',
slug: 'bbb'
})
},
getWorkspaceRole: async () => ok('workspace:member'),
getWorkspaceSsoProvider: async () => err(new WorkspaceSsoProviderNotFoundError()),
getWorkspaceSsoSession: async () => err(new WorkspaceSsoSessionNotFoundError())
getWorkspace: async () => ({
id: 'aaa',
slug: 'bbb'
}),
getWorkspaceRole: async () => 'workspace:member',
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => {
expect.fail()
}
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
expect(result).toStrictEqual(just(ok()))
})
it('throws uncovered error for unexpected ssoProvider loader errors', async () => {
const result = maybeMemberRoleWithValidSsoSessionIfNeeded({
getWorkspace: async () => {
return ok({
id: 'aaa',
slug: 'bbb'
})
},
getWorkspaceRole: async () => ok('workspace:member'),
// @ts-expect-error testing uncovered errors
getWorkspaceSsoProvider: async () => err(new ProjectNotFoundError()),
getWorkspaceSsoSession: async () => err(new WorkspaceSsoSessionNotFoundError())
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
await expect(result).rejects.toThrowError(/Uncovered error/)
})
it('throws uncovered error for unexpected ssoSession loader errors', async () => {
const result = maybeMemberRoleWithValidSsoSessionIfNeeded({
getWorkspace: async () => {
return ok({
id: 'aaa',
slug: 'bbb'
})
},
getWorkspaceRole: async () => ok('workspace:member'),
getWorkspaceSsoProvider: async () =>
ok({ providerId: cryptoRandomString({ length: 10 }) }),
// @ts-expect-error testing uncovered errors
getWorkspaceSsoSession: async () => err(new ProjectNotFoundError())
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
await expect(result).rejects.toThrowError(/Uncovered error/)
})
it('returns WorkspaceSsoSessionInvalidError if user does not have an SSO session', async () => {
const result = maybeMemberRoleWithValidSsoSessionIfNeeded({
getWorkspace: async () => {
return ok({
id: 'aaa',
slug: 'bbb'
})
},
getWorkspaceRole: async () => ok('workspace:member'),
getWorkspaceSsoProvider: async () =>
ok({ providerId: cryptoRandomString({ length: 10 }) }),
getWorkspaceSsoSession: async () => err(new WorkspaceSsoSessionNotFoundError())
getWorkspace: async () => ({
id: 'aaa',
slug: 'bbb'
}),
getWorkspaceRole: async () => 'workspace:member',
getWorkspaceSsoProvider: async () => ({
providerId: cryptoRandomString({ length: 10 })
}),
getWorkspaceSsoSession: async () => null
})({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
@@ -181,16 +112,15 @@ describe('maybeMemberRoleWithValidSsoSessionIfNeeded returns a function, that',
validUntil.setDate(validUntil.getDate() - 1)
const result = await maybeMemberRoleWithValidSsoSessionIfNeeded({
getWorkspace: async () => {
return ok({
id: 'aaa',
slug: 'bbb'
})
},
getWorkspaceRole: async () => ok('workspace:member'),
getWorkspaceSsoProvider: async () =>
ok({ providerId: cryptoRandomString({ length: 10 }) }),
getWorkspaceSsoSession: async () => ok({ providerId, validUntil, userId })
getWorkspace: async () => ({
id: 'aaa',
slug: 'bbb'
}),
getWorkspaceRole: async () => 'workspace:member',
getWorkspaceSsoProvider: async () => ({
providerId: cryptoRandomString({ length: 10 })
}),
getWorkspaceSsoSession: async () => ({ providerId, validUntil, userId })
})({
userId,
workspaceId
@@ -210,16 +140,15 @@ describe('maybeMemberRoleWithValidSsoSessionIfNeeded returns a function, that',
validUntil.setDate(validUntil.getDate() + 100)
const result = await maybeMemberRoleWithValidSsoSessionIfNeeded({
getWorkspace: async () => {
return ok({
id: 'aaa',
slug: 'bbb'
})
},
getWorkspaceRole: async () => ok('workspace:member'),
getWorkspaceSsoProvider: async () =>
ok({ providerId: cryptoRandomString({ length: 10 }) }),
getWorkspaceSsoSession: async () => ok({ providerId, validUntil, userId })
getWorkspace: async () => ({
id: 'aaa',
slug: 'bbb'
}),
getWorkspaceRole: async () => 'workspace:member',
getWorkspaceSsoProvider: async () => ({
providerId: cryptoRandomString({ length: 10 })
}),
getWorkspaceSsoSession: async () => ({ providerId, validUntil, userId })
})({
userId,
workspaceId
@@ -1,7 +1,9 @@
import { err, isErr, ok } from 'true-myth/result'
import { throwUncoveredError } from '../../core/helpers/error.js'
import { err, ok } from 'true-myth/result'
import { AuthPolicyFragment } from '../domain/policies.js'
import { requireMinimumWorkspaceRole } from '../checks/workspaceRole.js'
import {
hasAnyWorkspaceRole,
requireMinimumWorkspaceRole
} from '../checks/workspaceRole.js'
import { just, nothing } from 'true-myth/maybe'
import {
WorkspaceNoAccessError,
@@ -21,64 +23,46 @@ export const maybeMemberRoleWithValidSsoSessionIfNeeded: AuthPolicyFragment<
async ({ userId, workspaceId }) => {
// Get workspace, so we can resolve its slug for error scenarios
const workspace = await loaders.getWorkspace({ workspaceId })
if (workspace.isErr) {
switch (workspace.error.code) {
case 'WorkspaceNoAccess':
case 'WorkspaceNotFound':
return just(err(new WorkspaceNoAccessError()))
case 'WorkspaceSsoSessionNoAccess':
return just(err(workspace.error))
default:
throwUncoveredError(workspace.error)
}
}
// hides the fact, that the workspace does not exist
if (!workspace) return just(err(new WorkspaceNoAccessError()))
const hasAnyRole = await hasAnyWorkspaceRole(loaders)({ userId, workspaceId })
if (!hasAnyRole) return just(err(new WorkspaceNoAccessError()))
const hasMinimumMemberRole = await requireMinimumWorkspaceRole(loaders)({
userId,
workspaceId,
role: 'workspace:member'
})
// only members and above need to use sso
if (!hasMinimumMemberRole) return nothing()
const workspaceSsoProvider = await loaders.getWorkspaceSsoProvider({
workspaceId
})
if (isErr(workspaceSsoProvider)) {
switch (workspaceSsoProvider.error.code) {
case 'WorkspaceSsoProviderNotFound':
// if there is no SSO provider, we can early return here
return just(ok())
default:
throwUncoveredError(workspaceSsoProvider.error.code)
}
}
if (!workspaceSsoProvider) return just(ok())
const workspaceSsoSession = await loaders.getWorkspaceSsoSession({
userId,
workspaceId
})
if (isErr(workspaceSsoSession)) {
switch (workspaceSsoSession.error.code) {
case 'WorkspaceSsoSessionNotFound':
return just(
err(
new WorkspaceSsoSessionNoAccessError({
payload: { workspaceSlug: workspace.value.slug }
})
)
)
default:
throwUncoveredError(workspaceSsoSession.error.code)
}
}
if (!workspaceSsoSession)
return just(
err(
new WorkspaceSsoSessionNoAccessError({
payload: { workspaceSlug: workspace.slug }
})
)
)
const isExpiredSession =
new Date().getTime() > workspaceSsoSession.value.validUntil.getTime()
new Date().getTime() > workspaceSsoSession.validUntil.getTime()
if (isExpiredSession)
return just(
err(
new WorkspaceSsoSessionNoAccessError({
payload: { workspaceSlug: workspace.value.slug }
payload: { workspaceSlug: workspace.slug }
})
)
)
@@ -0,0 +1,31 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Result } from 'true-myth'
import { AuthError } from '../domain/authErrors.js'
export type GraphqlPermissionCheckResult = {
authorized: boolean
code: string
message: string
payload: unknown
}
export const toGraphqlResult = (
authResult: Result<unknown, AuthError<string, any>>
): GraphqlPermissionCheckResult => {
if (authResult.isOk) {
return {
authorized: true,
code: 'OK',
message: 'OK',
payload: null
}
} else {
const error = authResult.error
return {
authorized: false,
code: error.code,
message: error.message,
payload: error.payload || null
}
}
}
+1 -1
View File
@@ -4,5 +4,5 @@ export {
AuthCheckContextLoaders,
AuthCheckContextLoaderKeys
} from './domain/loaders.js'
export * from './helpers/graphql.js'
export * from './domain/authErrors.js'
@@ -0,0 +1,604 @@
import { assert, describe, expect, it } from 'vitest'
import {
ServerNoAccessError,
ServerNoSessionError,
WorkspaceLimitsReachedError,
WorkspaceNoAccessError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspaceReadOnlyError,
WorkspacesNotEnabledError,
WorkspaceSsoSessionNoAccessError
} from '../domain/authErrors.js'
import { nanoid } from 'nanoid'
import { canCreateWorkspaceProjectPolicy } from './canCreateWorkspaceProject.js'
import { parseFeatureFlags } from '../../environment/index.js'
import cryptoRandomString from 'crypto-random-string'
import { WorkspacePlan } from '../../workspaces/index.js'
import { Workspace, WorkspaceSsoProvider } from '../domain/workspaces/types.js'
import { err, ok } from 'true-myth/result'
const canCreateArgs = () => ({
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
describe('canCreateWorkspaceProjectPolicy creates a function, that handles', () => {
describe('server environment configuration by', () => {
it('forbids creation if the workspaces module is not enabled', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'false'
}),
getServerRole: async () => {
assert.fail()
},
getWorkspaceRole: async () => {
assert.fail()
},
getWorkspace: async () => {
assert.fail()
},
getWorkspaceSeat: async () => {
assert.fail()
},
getWorkspacePlan: async () => {
assert.fail()
},
getWorkspaceLimits: async () => {
assert.fail()
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
assert.fail()
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(err(new WorkspacesNotEnabledError()))
})
})
describe('user server roles', () => {
it('forbids creation for unknown users', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return null
},
getWorkspaceRole: async () => {
assert.fail()
},
getWorkspace: async () => {
assert.fail()
},
getWorkspaceSeat: async () => {
assert.fail()
},
getWorkspacePlan: async () => {
assert.fail()
},
getWorkspaceLimits: async () => {
assert.fail()
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
assert.fail()
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})({ workspaceId: '' })
expect(result).toStrictEqual(err(new ServerNoSessionError()))
})
it('forbids creation for anyone not having minimum server:user role', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:guest'
},
getWorkspaceRole: async () => {
assert.fail()
},
getWorkspace: async () => {
assert.fail()
},
getWorkspaceSeat: async () => {
assert.fail()
},
getWorkspacePlan: async () => {
assert.fail()
},
getWorkspaceLimits: async () => {
assert.fail()
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
assert.fail()
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(err(new ServerNoAccessError()))
})
})
describe('workspace sso', () => {
const workspaceSlug = nanoid()
it('forbids creation when workspace not found', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
assert.fail()
},
getWorkspace: async () => {
return null
},
getWorkspaceSeat: async () => {
assert.fail()
},
getWorkspacePlan: async () => {
assert.fail()
},
getWorkspaceLimits: async () => {
assert.fail()
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
assert.fail()
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(err(new WorkspaceNoAccessError()))
})
it('forbids creation when sso session is not found', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
return 'workspace:member'
},
getWorkspace: async () => {
return {
slug: workspaceSlug
} as Workspace
},
getWorkspaceSeat: async () => {
assert.fail()
},
getWorkspacePlan: async () => {
assert.fail()
},
getWorkspaceLimits: async () => {
assert.fail()
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
return {} as WorkspaceSsoProvider
},
getWorkspaceSsoSession: async () => {
return null
}
})(canCreateArgs())
expect(result).toStrictEqual(
err(
new WorkspaceSsoSessionNoAccessError({
payload: { workspaceSlug }
})
)
)
})
it('throws UncoveredError from unexpected sso session errors')
})
describe('user workspace roles', () => {
it('forbids creation for users without a workspace role', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
return null
},
getWorkspace: async () => {
return {} as Workspace
},
getWorkspaceSeat: async () => {
assert.fail()
},
getWorkspacePlan: async () => {
assert.fail()
},
getWorkspaceLimits: async () => {
assert.fail()
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
assert.fail()
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(err(new WorkspaceNoAccessError()))
})
it('WorkspaceNotEnoughPermissionsError for workspace guests', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
return 'workspace:guest'
},
getWorkspace: async () => {
return {} as Workspace
},
getWorkspaceSeat: async () => {
assert.fail()
},
getWorkspacePlan: async () => {
assert.fail()
},
getWorkspaceLimits: async () => {
assert.fail()
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
assert.fail()
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(
err(
new WorkspaceNotEnoughPermissionsError({
message: 'Guests cannot create projects in the workspace'
})
)
)
})
it('forbids non-editor seats from creating projects', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
return 'workspace:member'
},
getWorkspace: async () => {
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
},
getWorkspacePlan: async () => {
return {
status: 'valid',
name: 'team'
} as WorkspacePlan
},
getWorkspaceLimits: async () => {
assert.fail()
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
return null
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(err(new WorkspaceNoEditorSeatError()))
})
})
describe('workspace plans', () => {
it('forbids creation if plan fails to load', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
return 'workspace:member'
},
getWorkspace: async () => {
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
},
getWorkspacePlan: async () => {
return null
},
getWorkspaceLimits: async () => {
assert.fail()
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
return null
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(err(new WorkspaceNoAccessError()))
})
it('forbids creation if plan is read-only', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
return 'workspace:member'
},
getWorkspace: async () => {
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
},
getWorkspacePlan: async () => {
return {
status: 'expired'
} as WorkspacePlan
},
getWorkspaceLimits: async () => {
assert.fail()
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
return null
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(err(new WorkspaceReadOnlyError()))
})
})
describe('workspace limits', () => {
it('forbids creation if limits fail to load', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
return 'workspace:member'
},
getWorkspace: async () => {
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
},
getWorkspacePlan: async () => {
return {
status: 'valid'
} as WorkspacePlan
},
getWorkspaceLimits: async () => {
return null
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
return null
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(err(new WorkspaceNoAccessError()))
})
it('allows creation if plan has no limits', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
return 'workspace:member'
},
getWorkspace: async () => {
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
},
getWorkspacePlan: async () => {
return {
status: 'valid'
} as WorkspacePlan
},
getWorkspaceLimits: async () => {
return {
projectCount: null,
modelCount: null
}
},
getWorkspaceProjectCount: async () => {
assert.fail()
},
getWorkspaceSsoProvider: async () => {
return null
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(ok())
})
it('forbids creation if current project count fails to load', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
return 'workspace:member'
},
getWorkspace: async () => {
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
},
getWorkspacePlan: async () => {
return {
status: 'valid'
} as WorkspacePlan
},
getWorkspaceLimits: async () => {
return {
projectCount: 10,
modelCount: 50
}
},
getWorkspaceProjectCount: async () => {
return null
},
getWorkspaceSsoProvider: async () => {
return null
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(err(new WorkspaceNoAccessError()))
})
it('allows creation if new project is within plan limits', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
return 'workspace:member'
},
getWorkspace: async () => {
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
},
getWorkspacePlan: async () => {
return {
status: 'valid'
} as WorkspacePlan
},
getWorkspaceLimits: async () => {
return {
projectCount: 10,
modelCount: 50
}
},
getWorkspaceProjectCount: async () => {
return 5
},
getWorkspaceSsoProvider: async () => {
return null
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(ok())
})
it('forbids creation if new project is not within plan limits', async () => {
const result = await canCreateWorkspaceProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => {
return 'server:user'
},
getWorkspaceRole: async () => {
return 'workspace:member'
},
getWorkspace: async () => {
return {} as Workspace
},
getWorkspaceSeat: async () => {
return 'viewer'
},
getWorkspacePlan: async () => {
return {
status: 'valid'
} as WorkspacePlan
},
getWorkspaceLimits: async () => {
return {
projectCount: 10,
modelCount: 50
}
},
getWorkspaceProjectCount: async () => {
return 10
},
getWorkspaceSsoProvider: async () => {
return null
},
getWorkspaceSsoSession: async () => {
assert.fail()
}
})(canCreateArgs())
expect(result).toStrictEqual(
err(new WorkspaceLimitsReachedError({ payload: { limit: 'projectCount' } }))
)
})
})
})
@@ -0,0 +1,113 @@
import { AuthPolicy } from '../domain/policies.js'
import {
ServerNoAccessError,
ServerNoSessionError,
WorkspaceLimitsReachedError,
WorkspaceNoAccessError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspaceReadOnlyError,
WorkspacesNotEnabledError,
WorkspaceSsoSessionNoAccessError
} from '../domain/authErrors.js'
import { err, ok } from 'true-myth/result'
import { hasMinimumServerRole } from '../checks/serverRole.js'
import { Roles } from '../../core/constants.js'
import { maybeMemberRoleWithValidSsoSessionIfNeeded } from '../fragments/workspaceSso.js'
import { throwUncoveredError } from '../../core/index.js'
import { hasEditorSeat } from '../checks/workspaceSeat.js'
import { MaybeUserContext, WorkspaceContext } from '../domain/context.js'
import {
isNewWorkspacePlan,
isWorkspacePlanStatusReadOnly
} from '../../workspaces/index.js'
export const canCreateWorkspaceProjectPolicy: AuthPolicy<
| 'getEnv'
| 'getServerRole'
| 'getWorkspace'
| 'getWorkspaceRole'
| 'getWorkspaceSeat'
| 'getWorkspacePlan'
| 'getWorkspaceLimits'
| 'getWorkspaceProjectCount'
| 'getWorkspaceSsoProvider'
| 'getWorkspaceSsoSession',
MaybeUserContext & WorkspaceContext,
| InstanceType<typeof WorkspacesNotEnabledError>
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceReadOnlyError>
| InstanceType<typeof WorkspaceNoEditorSeatError>
| InstanceType<typeof WorkspaceNotEnoughPermissionsError>
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
| InstanceType<typeof WorkspaceLimitsReachedError>
| InstanceType<typeof ServerNoSessionError>
| InstanceType<typeof ServerNoAccessError>
> =
(loaders) =>
async ({ userId, workspaceId }) => {
const env = await loaders.getEnv()
if (!userId) return err(new ServerNoSessionError())
if (!env.FF_WORKSPACES_MODULE_ENABLED) return err(new WorkspacesNotEnabledError())
const isActiveServerUser = await hasMinimumServerRole(loaders)({
userId,
role: Roles.Server.User
})
if (!isActiveServerUser) return err(new ServerNoAccessError())
const memberWithSsoSession = await maybeMemberRoleWithValidSsoSessionIfNeeded(
loaders
)({
userId,
workspaceId
})
// guests cannot create projects in the workspace
if (memberWithSsoSession.isNothing)
return err(
new WorkspaceNotEnoughPermissionsError({
message: 'Guests cannot create projects in the workspace'
})
)
// if sso session is not valid, return errors
if (memberWithSsoSession.value.isErr) {
switch (memberWithSsoSession.value.error.code) {
case 'WorkspaceNoAccess':
case 'WorkspaceSsoSessionNoAccess':
return err(memberWithSsoSession.value.error)
default:
throwUncoveredError(memberWithSsoSession.value.error)
}
}
const workspacePlan = await loaders.getWorkspacePlan({ workspaceId })
if (!workspacePlan) return err(new WorkspaceNoAccessError())
if (isWorkspacePlanStatusReadOnly(workspacePlan.status))
return err(new WorkspaceReadOnlyError())
if (isNewWorkspacePlan(workspacePlan.name)) {
const isEditor = await hasEditorSeat(loaders)({
userId,
workspaceId
})
if (!isEditor) return err(new WorkspaceNoEditorSeatError())
}
const workspaceLimits = await loaders.getWorkspaceLimits({ workspaceId })
if (!workspaceLimits) return err(new WorkspaceNoAccessError())
// no limits imposed
if (workspaceLimits.projectCount === null) return ok()
const currentProjectCount = await loaders.getWorkspaceProjectCount({
workspaceId
})
// this will not happen in practice
if (currentProjectCount === null) return err(new WorkspaceNoAccessError())
return currentProjectCount < workspaceLimits.projectCount
? ok()
: err(new WorkspaceLimitsReachedError({ payload: { limit: 'projectCount' } }))
}
@@ -1,77 +1,39 @@
import { describe, expect, it, assert } from 'vitest'
import { canQueryProjectPolicy } from './canQueryProject.js'
import { canReadProjectPolicy } from './canReadProject.js'
import { parseFeatureFlags } from '../../environment/index.js'
import crs from 'crypto-random-string'
import { Roles } from '../../core/constants.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ProjectRoleNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerRoleNotFoundError,
WorkspaceNoAccessError,
WorkspaceRoleNotFoundError,
WorkspaceSsoProviderNotFoundError,
WorkspaceSsoSessionNoAccessError,
WorkspaceSsoSessionNotFoundError
WorkspaceSsoSessionNoAccessError
} from '../domain/authErrors.js'
import { getProjectFake } from '../../tests/fakes.js'
import { err, ok } from 'true-myth/result'
import cryptoRandomString from 'crypto-random-string'
import { AuthCheckContextLoaders } from '../domain/loaders.js'
const canQueryProjectArgs = () => {
const canReadProjectArgs = () => {
const projectId = crs({ length: 10 })
const userId = crs({ length: 10 })
return { projectId, userId }
}
const getWorkspace: AuthCheckContextLoaders['getWorkspace'] = async () =>
ok({
id: 'aaa',
slug: 'bbb'
})
const getWorkspace: AuthCheckContextLoaders['getWorkspace'] = async () => ({
id: 'aaa',
slug: 'bbb'
})
describe('canQueryProjectPolicy creates a function, that handles ', () => {
describe('project loader errors', () => {
it.each([
ProjectNoAccessError,
ProjectNotFoundError,
WorkspaceSsoSessionNoAccessError
])('expected $code error by returning the error', async (expectedError) => {
const result = await canQueryProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () =>
err(new expectedError({ payload: { workspaceSlug: 'bbb' } })),
getProjectRole: () => {
assert.fail()
},
getServerRole: () => {
assert.fail()
},
getWorkspace,
getWorkspaceRole: () => {
assert.fail()
},
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})(canQueryProjectArgs())
expect(result).toStrictEqual(
err(new expectedError({ payload: { workspaceSlug: 'bbb' } }))
)
})
it('unexpected error by throwing UncoveredError', async () => {
const result = canQueryProjectPolicy({
describe('canReadProjectPolicy creates a function, that handles ', () => {
describe('project loader', () => {
it('converts not found projects into ProjectNotFoundError', async () => {
const result = canReadProjectPolicy({
getWorkspace,
getEnv: async () => parseFeatureFlags({}),
// @ts-expect-error testing uncovered error handling
getProject: async () => err(ProjectRoleNotFoundError),
getProject: async () => null,
getProjectRole: () => {
assert.fail()
},
@@ -87,14 +49,14 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
getWorkspaceSsoProvider: () => {
assert.fail()
}
})(canQueryProjectArgs())
})(canReadProjectArgs())
await expect(result).rejects.toThrowError(/Uncovered error/)
await expect(result).resolves.toStrictEqual(err(new ProjectNotFoundError()))
})
})
describe('project visibility', () => {
it('allows anyone on a public project', async () => {
const canQueryProject = canQueryProjectPolicy({
const canReadProject = canReadProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({ isPublic: true }),
getProjectRole: () => {
@@ -115,11 +77,11 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
const canQuery = await canReadProject(canReadProjectArgs())
expect(canQuery.isOk).toBe(true)
})
it('allows anyone on a linkShareable project', async () => {
const canQueryProject = canQueryProjectPolicy({
const canReadProject = canReadProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({ isDiscoverable: true }),
getProjectRole: () => {
@@ -139,19 +101,19 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
const canQuery = await canReadProject(canReadProjectArgs())
expect(canQuery.isOk).toBe(true)
})
})
describe('server roles', () => {
it('allows access for archived server users with a project role on a public project', async () => {
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }),
getProject: getProjectFake({ isDiscoverable: false, isPublic: true }),
getProjectRole: async () => ok(Roles.Stream.Owner),
getServerRole: async () => ok(Roles.Server.ArchivedUser),
getProjectRole: async () => Roles.Stream.Owner,
getServerRole: async () => Roles.Server.ArchivedUser,
getWorkspace,
getWorkspaceRole: () => {
assert.fail()
@@ -162,16 +124,16 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
getWorkspaceSsoProvider: () => {
assert.fail()
}
})(canQueryProjectArgs())
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(ok())
})
it('does not allow access for archived server users with a project role', async () => {
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }),
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
getProjectRole: async () => ok(Roles.Stream.Owner),
getServerRole: async () => ok(Roles.Server.ArchivedUser),
getProjectRole: async () => Roles.Stream.Owner,
getServerRole: async () => Roles.Server.ArchivedUser,
getWorkspace,
getWorkspaceRole: () => {
assert.fail()
@@ -182,16 +144,16 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
getWorkspaceSsoProvider: () => {
assert.fail()
}
})(canQueryProjectArgs())
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(err(new ServerNoAccessError()))
})
it('does not allow access for non public projects for unknown users', async () => {
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }),
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
getProjectRole: async () => ok(Roles.Stream.Owner),
getServerRole: async () => err(new ServerRoleNotFoundError()),
getProjectRole: async () => Roles.Stream.Owner,
getServerRole: async () => null,
getWorkspace,
getWorkspaceRole: () => {
@@ -212,12 +174,12 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
it.each(Object.values(Roles.Stream))(
'allows access for active server users to private projects with %s role',
async (role) => {
const canQueryProject = canQueryProjectPolicy({
const canReadProject = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }),
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
getProjectRole: async () => ok(role),
getServerRole: async () => ok('server:user'),
getProjectRole: async () => role,
getServerRole: async () => Roles.Server.User,
getWorkspace,
getWorkspaceRole: () => {
assert.fail()
@@ -229,18 +191,18 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
const canQuery = await canReadProject(canReadProjectArgs())
expect(canQuery.isOk).toBe(true)
}
)
it('does not allow access to private projects without a project role', async () => {
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }),
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
getProjectRole: async () => err(new ProjectRoleNotFoundError()),
getProjectRole: async () => null,
getWorkspace,
getServerRole: async () => ok(Roles.Server.Admin),
getServerRole: async () => Roles.Server.Admin,
getWorkspaceRole: () => {
assert.fail()
},
@@ -250,16 +212,16 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
getWorkspaceSsoProvider: () => {
assert.fail()
}
})(canQueryProjectArgs())
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(err(new ProjectNoAccessError()))
})
})
describe('admin override', () => {
it('allows server admins without project roles on private projects if admin override is enabled', async () => {
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () => parseFeatureFlags({ FF_ADMIN_OVERRIDE_ENABLED: 'true' }),
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
getServerRole: async () => ok(Roles.Server.Admin),
getServerRole: async () => Roles.Server.Admin,
getProjectRole: () => {
assert.fail()
},
@@ -273,20 +235,20 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
getWorkspaceSsoProvider: () => {
assert.fail()
}
})(canQueryProjectArgs())
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(ok())
})
it('does not allow server admins without project roles on private projects if admin override is disabled', async () => {
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_ADMIN_OVERRIDE_ENABLED: 'false',
FF_WORKSPACES_MODULE_ENABLED: 'false'
}),
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
getServerRole: async () => ok(Roles.Server.Admin),
getProjectRole: async () => err(new ProjectRoleNotFoundError()),
getServerRole: async () => Roles.Server.Admin,
getProjectRole: async () => null,
getWorkspace,
getWorkspaceRole: () => {
@@ -298,13 +260,13 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
getWorkspaceSsoProvider: () => {
assert.fail()
}
})(canQueryProjectArgs())
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(err(new ProjectNoAccessError()))
})
})
describe('the workspace world', () => {
it('does not check workspace rules if the workspaces module is not enabled', async () => {
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }),
getProject: getProjectFake({
@@ -312,8 +274,8 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: async () => ok('stream:contributor'),
getServerRole: async () => ok('server:user'),
getProjectRole: async () => Roles.Stream.Contributor,
getServerRole: async () => Roles.Server.User,
getWorkspace,
getWorkspaceRole: () => {
@@ -325,11 +287,11 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
getWorkspaceSsoProvider: () => {
assert.fail()
}
})(canQueryProjectArgs())
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(ok())
})
it('does not allow project access without a workspace role', async () => {
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
@@ -339,22 +301,22 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: async () => ok('stream:contributor'),
getServerRole: async () => ok('server:user'),
getProjectRole: async () => Roles.Stream.Contributor,
getServerRole: async () => Roles.Server.User,
getWorkspace,
getWorkspaceRole: async () => err(new WorkspaceRoleNotFoundError()),
getWorkspaceRole: async () => null,
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})(canQueryProjectArgs())
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(err(new WorkspaceNoAccessError()))
})
it('allows project access via workspace role if user does not have project role', async () => {
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
@@ -364,20 +326,19 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: async () => err(new ProjectRoleNotFoundError()),
getServerRole: async () => ok('server:user'),
getWorkspaceRole: async () => ok('workspace:admin'),
getProjectRole: async () => null,
getServerRole: async () => Roles.Server.User,
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspace,
getWorkspaceSsoProvider: async () =>
err(new WorkspaceSsoProviderNotFoundError())
})(canQueryProjectArgs())
getWorkspaceSsoProvider: async () => null
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(ok())
})
it('does not check SSO sessions if user is workspace guest', async () => {
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
@@ -387,21 +348,21 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: async () => ok('stream:contributor'),
getServerRole: async () => ok('server:user'),
getProjectRole: async () => Roles.Stream.Contributor,
getServerRole: async () => Roles.Server.User,
getWorkspace,
getWorkspaceRole: async () => ok('workspace:guest'),
getWorkspaceRole: async () => Roles.Workspace.Guest,
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})(canQueryProjectArgs())
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(ok())
})
it('does not check SSO sessions if workspace does not have it enabled', async () => {
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
@@ -411,20 +372,19 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: async () => ok('stream:contributor'),
getServerRole: async () => ok('server:user'),
getWorkspaceRole: async () => ok('workspace:member'),
getProjectRole: async () => Roles.Stream.Contributor,
getServerRole: async () => Roles.Server.User,
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspace,
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: async () =>
err(new WorkspaceSsoProviderNotFoundError())
})(canQueryProjectArgs())
getWorkspaceSsoProvider: async () => null
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(ok())
})
it('does not allow project access if SSO session is missing', async () => {
const canQueryProject = canQueryProjectPolicy({
const canReadProject = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
@@ -434,21 +394,21 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: async () => ok('stream:contributor'),
getServerRole: async () => ok('server:user'),
getProjectRole: async () => Roles.Stream.Contributor,
getServerRole: async () => Roles.Server.User,
getWorkspace,
getWorkspaceRole: async () => ok('workspace:member'),
getWorkspaceSsoSession: async () => err(new WorkspaceSsoSessionNotFoundError()),
getWorkspaceSsoProvider: async () => ok({ providerId: 'foo' })
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoSession: async () => null,
getWorkspaceSsoProvider: async () => ({ providerId: 'foo' })
})
const canQuery = await canQueryProject(canQueryProjectArgs())
const canQuery = await canReadProject(canReadProjectArgs())
expect(canQuery.isOk).toBe(false)
})
it('does not allow project access if SSO session is not found', async () => {
const date = new Date()
date.setDate(date.getDate() - 1)
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
@@ -458,13 +418,13 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: async () => ok('stream:contributor'),
getServerRole: async () => ok('server:user'),
getWorkspaceRole: async () => ok('workspace:member'),
getProjectRole: async () => Roles.Stream.Contributor,
getServerRole: async () => Roles.Server.User,
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspace,
getWorkspaceSsoSession: async () => err(new WorkspaceSsoSessionNotFoundError()),
getWorkspaceSsoProvider: async () => ok({ providerId: 'foo' })
})(canQueryProjectArgs())
getWorkspaceSsoSession: async () => null,
getWorkspaceSsoProvider: async () => ({ providerId: 'foo' })
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(
err(
new WorkspaceSsoSessionNoAccessError({
@@ -477,7 +437,7 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
const date = new Date()
date.setDate(date.getDate() - 1)
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
@@ -487,14 +447,17 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: async () => ok('stream:contributor'),
getServerRole: async () => ok('server:user'),
getProjectRole: async () => Roles.Stream.Contributor,
getServerRole: async () => Roles.Server.User,
getWorkspace,
getWorkspaceRole: async () => ok('workspace:member'),
getWorkspaceSsoSession: async () =>
ok({ validUntil: date, userId: 'foo', providerId: 'foo' }),
getWorkspaceSsoProvider: async () => ok({ providerId: 'foo' })
})(canQueryProjectArgs())
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoSession: async () => ({
validUntil: date,
userId: 'foo',
providerId: 'foo'
}),
getWorkspaceSsoProvider: async () => ({ providerId: 'foo' })
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(
err(
new WorkspaceSsoSessionNoAccessError({
@@ -507,7 +470,7 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
const date = new Date()
date.setDate(date.getDate() + 1)
const result = canQueryProjectPolicy({
const result = canReadProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
@@ -517,14 +480,17 @@ describe('canQueryProjectPolicy creates a function, that handles ', () => {
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: async () => ok('stream:contributor'),
getServerRole: async () => ok('server:user'),
getProjectRole: async () => Roles.Stream.Contributor,
getServerRole: async () => Roles.Server.User,
getWorkspace,
getWorkspaceRole: async () => ok('workspace:member'),
getWorkspaceSsoSession: async () =>
ok({ validUntil: date, userId: 'foo', providerId: 'foo' }),
getWorkspaceSsoProvider: async () => ok({ providerId: 'foo' })
})(canQueryProjectArgs())
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoSession: async () => ({
validUntil: date,
userId: 'foo',
providerId: 'foo'
}),
getWorkspaceSsoProvider: async () => ({ providerId: 'foo' })
})(canReadProjectArgs())
await expect(result).resolves.toStrictEqual(ok())
})
})
@@ -10,13 +10,13 @@ import {
} from '../domain/authErrors.js'
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../domain/loaders.js'
import { AuthPolicy, MaybeUserContext, ProjectContext } from '../domain/policies.js'
import { AuthPolicy } from '../domain/policies.js'
import { canUseAdminOverride, hasMinimumServerRole } from '../checks/serverRole.js'
import { hasAnyWorkspaceRole } from '../checks/workspaceRole.js'
import { maybeMemberRoleWithValidSsoSessionIfNeeded } from '../fragments/workspaceSso.js'
import { throwUncoveredError } from '../../core/index.js'
import { MaybeUserContext, ProjectContext } from '../domain/context.js'
export const canQueryProjectPolicy: AuthPolicy<
export const canReadProjectPolicy: AuthPolicy<
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getProject
| typeof AuthCheckContextLoaderKeys.getProjectRole
@@ -37,22 +37,8 @@ export const canQueryProjectPolicy: AuthPolicy<
async ({ userId, projectId }) => {
const env = await loaders.getEnv()
// we prerolad the project and early return any loading errors
// this is a short circuit in the frontend, to surface any project load errors
// from the backend.
// make sure to expose all of the error types in the loader type,
// that we care about in this early return
const project = await loaders.getProject({ projectId })
if (project.isErr) {
switch (project.error.code) {
case 'ProjectNoAccess':
case 'ProjectNotFound':
case 'WorkspaceSsoSessionNoAccess':
return err(project.error)
default:
throwUncoveredError(project.error)
}
}
if (!project) return err(new ProjectNotFoundError())
// All users may read public projects
if (await isPubliclyReadableProject(loaders)({ projectId })) return ok()
@@ -68,7 +54,8 @@ export const canQueryProjectPolicy: AuthPolicy<
// When G O D M O D E is enabled
if (await canUseAdminOverride(loaders)({ userId })) return ok()
const { workspaceId } = project.value
// todo
const { workspaceId } = project
// When a project belongs to a workspace
if (env.FF_WORKSPACES_MODULE_ENABLED && !!workspaceId) {
// User must have a workspace role to read project data
+6 -2
View File
@@ -1,9 +1,13 @@
import { AllAuthCheckContextLoaders } from '../domain/loaders.js'
import { canQueryProjectPolicy } from './canQueryProject.js'
import { canCreateWorkspaceProjectPolicy } from './canCreateWorkspaceProject.js'
import { canReadProjectPolicy } from './canReadProject.js'
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
project: {
canQuery: canQueryProjectPolicy(loaders)
canRead: canReadProjectPolicy(loaders)
},
workspace: {
canCreateProject: canCreateWorkspaceProjectPolicy(loaders)
}
})
+4 -5
View File
@@ -1,20 +1,19 @@
import { merge } from 'lodash'
import { Project } from '../authz/domain/projects/types.js'
import { ok, Result } from 'true-myth/result'
import { nanoid } from 'nanoid'
export const fakeGetFactory =
<T extends Record<string, unknown>>(defaults: T) =>
(overrides?: Partial<T>) =>
(): Promise<Result<T, never>> => {
(): Promise<T> => {
if (overrides) {
return Promise.resolve(ok(merge(defaults, overrides)))
return Promise.resolve(merge(defaults, overrides))
}
return Promise.resolve(ok(defaults))
return Promise.resolve(defaults)
}
export const getProjectFake = fakeGetFactory<Project>({
id: nanoid(),
id: nanoid(10),
isPublic: false,
isDiscoverable: false,
workspaceId: null
@@ -1,4 +1,5 @@
import { WorkspaceRoles } from '../../core/constants.js'
import { WorkspaceLimits } from './limits.js'
import {
PaidWorkspacePlans,
UnpaidWorkspacePlans,
@@ -83,9 +84,15 @@ export type WorkspacePlanPriceStructure = {
}
}
const unlimited: WorkspaceLimits = {
projectCount: null,
modelCount: null
}
export type WorkspacePlanConfig<Plan extends WorkspacePlans = WorkspacePlans> = {
plan: Plan
features: readonly WorkspacePlanFeatures[]
limits: WorkspaceLimits
}
const baseFeatures = [
@@ -101,7 +108,8 @@ export const WorkspacePaidPlanConfigs: {
// Old
[PaidWorkspacePlans.Starter]: {
plan: PaidWorkspacePlans.Starter,
features: [...baseFeatures, WorkspacePlanFeatures.DomainSecurity]
features: [...baseFeatures, WorkspacePlanFeatures.DomainSecurity],
limits: unlimited
},
[PaidWorkspacePlans.Plus]: {
plan: PaidWorkspacePlans.Plus,
@@ -109,7 +117,8 @@ export const WorkspacePaidPlanConfigs: {
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO
]
],
limits: unlimited
},
[PaidWorkspacePlans.Business]: {
plan: PaidWorkspacePlans.Business,
@@ -119,11 +128,16 @@ export const WorkspacePaidPlanConfigs: {
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.PrioritySupport
]
],
limits: unlimited
},
[PaidWorkspacePlans.Team]: {
plan: PaidWorkspacePlans.Team,
features: baseFeatures
features: baseFeatures,
limits: {
projectCount: 5,
modelCount: 25
}
},
[PaidWorkspacePlans.Pro]: {
plan: PaidWorkspacePlans.Pro,
@@ -133,7 +147,11 @@ export const WorkspacePaidPlanConfigs: {
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.PrioritySupport
]
],
limits: {
projectCount: 10,
modelCount: 50
}
}
}
@@ -149,7 +167,8 @@ export const WorkspaceUnpaidPlanConfigs: {
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.PrioritySupport
]
],
limits: unlimited
},
[UnpaidWorkspacePlans.Academia]: {
plan: UnpaidWorkspacePlans.Academia,
@@ -159,7 +178,8 @@ export const WorkspaceUnpaidPlanConfigs: {
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.PrioritySupport
]
],
limits: unlimited
},
[UnpaidWorkspacePlans.StarterInvoiced]: {
...WorkspacePaidPlanConfigs.starter,
@@ -176,7 +196,11 @@ export const WorkspaceUnpaidPlanConfigs: {
// New
[UnpaidWorkspacePlans.Free]: {
plan: UnpaidWorkspacePlans.Free,
features: baseFeatures
features: baseFeatures,
limits: {
projectCount: 1,
modelCount: 5
}
}
}
@@ -0,0 +1,4 @@
export type WorkspaceLimits = {
projectCount: number | null
modelCount: number | null
}
@@ -1,3 +1,4 @@
import { throwUncoveredError } from '../../core/helpers/error.js'
import type { MaybeNullOrUndefined } from '../../core/helpers/utilityTypes.js'
/**
@@ -121,3 +122,39 @@ export const WorkspacePlanStatuses = <const>{
export type WorkspacePlanStatuses =
(typeof WorkspacePlanStatuses)[keyof typeof WorkspacePlanStatuses]
type BaseWorkspacePlan = {
workspaceId: string
createdAt: Date
}
export type PaidWorkspacePlan = BaseWorkspacePlan & {
name: PaidWorkspacePlans
status: PaidWorkspacePlanStatuses
}
export type TrialWorkspacePlan = BaseWorkspacePlan & {
name: TrialEnabledPaidWorkspacePlans
status: TrialWorkspacePlanStatuses
}
export type UnpaidWorkspacePlan = BaseWorkspacePlan & {
name: UnpaidWorkspacePlans
status: UnpaidWorkspacePlanStatuses
}
export type WorkspacePlan = PaidWorkspacePlan | TrialWorkspacePlan | UnpaidWorkspacePlan
export const isWorkspacePlanStatusReadOnly = (status: WorkspacePlan['status']) => {
switch (status) {
case 'cancelationScheduled':
case 'valid':
case 'trial':
case 'paymentFailed':
return false
case 'expired':
case 'canceled':
return true
default:
throwUncoveredError(status)
}
}