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:
@@ -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!
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' } }))
|
||||
}
|
||||
+106
-140
@@ -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())
|
||||
})
|
||||
})
|
||||
+6
-19
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user