feat(gatekeeper): readOnly validation logic

This commit is contained in:
Alessandro Magionami
2024-12-13 17:59:01 +01:00
parent a8decee3a4
commit 11beefeada
12 changed files with 495 additions and 19 deletions
@@ -4152,6 +4152,8 @@ export type Workspace = {
name: Scalars['String']['output'];
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 */
readOnly: Scalars['Boolean']['output'];
/** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */
role?: Maybe<Scalars['String']['output']>;
slug: Scalars['String']['output'];
@@ -7994,6 +7996,7 @@ export type WorkspaceFieldArgs = {
name: {},
plan: {},
projects: WorkspaceProjectsArgs,
readOnly: {},
role: {},
slug: {},
sso: {},
@@ -10,6 +10,7 @@ import {
} from '@/modules/core/repositories/objects'
import { createObjectsFactory } from '@/modules/core/services/objects/management'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import coreModule from '@/modules/core'
type GetObjectChildrenQueryParams = Parameters<
ReturnType<typeof getObjectChildrenQueryFactory>
@@ -100,6 +101,10 @@ export = {
context.resourceAccessRules
)
await coreModule.executeHooks?.('onCreateObjectRequest', {
projectId: args.objectInput.streamId
})
const projectDB = await getProjectDbClient({
projectId: args.objectInput.streamId
})
+6 -1
View File
@@ -20,11 +20,12 @@ import {
import { validatePermissionsWriteStreamFactory } from '@/modules/core/services/streams/auth'
import { authorizeResolver, validateScopes } from '@/modules/shared'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { ExecuteHooks } from '@/modules/core/hooks'
const MAX_FILE_SIZE = maximumObjectUploadFileSizeMb() * 1024 * 1024
const { FF_NO_CLOSURE_WRITES } = getFeatureFlags()
export default (app: Router) => {
export default (app: Router, { executeHooks }: { executeHooks: ExecuteHooks }) => {
const validatePermissionsWriteStream = validatePermissionsWriteStreamFactory({
validateScopes,
authorizeResolver
@@ -63,6 +64,10 @@ export default (app: Router) => {
return res.status(hasStreamAccess.status).end()
}
await executeHooks('onCreateObjectRequest', {
projectId: req.params.streamId
})
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
const objectInsertionService = FF_NO_CLOSURE_WRITES
@@ -363,7 +363,10 @@ describe('GraphQL API Core @core-api', () => {
const res = await sendRequest(userA.token, {
query: 'mutation($user:UserUpdateInput!) { userUpdate( user: $user) } ',
variables: {
user: { name: 'Miticå', bio: 'He never really knows what he is doing.' }
user: {
name: 'test user updated',
bio: 'He never really knows what he is doing.'
}
}
})
expect(res).to.be.json
@@ -1215,7 +1218,7 @@ describe('GraphQL API Core @core-api', () => {
expect(res).to.be.json
expect(res.body.errors).to.not.exist
expect(res.body.data).to.have.property('user')
expect(res.body.data.user.name).to.equal('Miticå')
expect(res.body.data.user.name).to.equal('test user updated')
expect(res.body.data.user.email).to.equal('d.1@speckle.systems')
expect(res.body.data.user.role).to.equal(Roles.Server.Admin)
})
@@ -0,0 +1,140 @@
import { beforeEachContext } from '@/test/hooks'
import { expect } from 'chai'
import { describe, it } from 'mocha'
import {
createRandomEmail,
createRandomPassword
} from '@/modules/core/helpers/testHelpers'
import {
createUserEmailFactory,
ensureNoPrimaryEmailForUserFactory,
findEmailFactory
} from '@/modules/core/repositories/userEmails'
import { db } from '@/db/knex'
import { testApolloServer } from '@/test/graphqlHelper'
import {
CreateObjectDocument,
CreateWorkspaceDocument,
CreateWorkspaceProjectDocument
} from '@/test/graphql/generated/graphql'
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing'
import {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request'
import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories'
import { renderEmail } from '@/modules/emails/services/emailRendering'
import { sendEmail } from '@/modules/emails/services/sending'
import {
countAdminUsersFactory,
legacyGetUserFactory,
storeUserAclFactory,
storeUserFactory
} from '@/modules/core/repositories/users'
import { createUserFactory } from '@/modules/core/services/users/management'
import { UsersEmitter } from '@/modules/core/events/usersEmitter'
import { getServerInfoFactory } from '@/modules/core/repositories/server'
import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing'
const getServerInfo = getServerInfoFactory({ db })
const getUser = legacyGetUserFactory({ db })
const requestNewEmailVerification = requestNewEmailVerificationFactory({
findEmail: findEmailFactory({ db }),
getUser,
getServerInfo,
deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }),
renderEmail,
sendEmail
})
const createUserEmail = validateAndCreateUserEmailFactory({
createUserEmail: createUserEmailFactory({ db }),
ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }),
findEmail: findEmailFactory({ db }),
updateEmailInvites: finalizeInvitedServerRegistrationFactory({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
}),
requestNewEmailVerification
})
const findEmail = findEmailFactory({ db })
const createUser = createUserFactory({
getServerInfo,
findEmail,
storeUser: storeUserFactory({ db }),
countAdminUsers: countAdminUsersFactory({ db }),
storeUserAcl: storeUserAclFactory({ db }),
validateAndCreateUserEmail: createUserEmail,
usersEventsEmitter: UsersEmitter.emit
})
describe('Objects graphql @core', () => {
before(async () => {
await beforeEachContext()
})
describe('objectCreate mutation', () => {
it('should return error if project is read-only', async () => {
const userId = await createUser({
name: 'emails user',
email: createRandomEmail(),
password: createRandomPassword()
})
const apollo = await testApolloServer({ authUserId: userId })
const workspaceCreateRes = await apollo.execute(CreateWorkspaceDocument, {
input: { name: 'test ws' }
})
expect(workspaceCreateRes).to.not.haveGraphQLErrors()
const workspace = workspaceCreateRes.data?.workspaceMutations.create
const projectCreateRes = await apollo.execute(CreateWorkspaceProjectDocument, {
input: { workspaceId: workspace!.id, name: 'test project' }
})
expect(projectCreateRes).to.not.haveGraphQLErrors()
const project = projectCreateRes.data?.workspaceMutations.projects.create
// Make the project read-only
await db('workspace_plans')
.update({ status: 'expired' })
.where({ workspaceId: workspace!.id })
const objectCreateRes = await apollo.execute(CreateObjectDocument, {
input: {
streamId: project!.id,
objects: [
{
id: 'e5262a6fb51540974e6d07ac60b7fe5c',
name: 'Rhino Model',
elements: [
{
referencedId: '581a822cdaa5c2972783510d57617f73',
/* eslint-disable camelcase */
speckle_type: 'reference'
}
],
__closure: {
'0086c072ee1fd70ac0a68c067a37e0eb': 3
},
speckleType: 'Speckle.Core.Models.Collection',
speckle_type: 'Speckle.Core.Models.Collection',
applicationId: null,
collectionType: 'rhino model',
totalChildrenCount: 610
}
]
}
})
expect(objectCreateRes).to.haveGraphQLErrors()
expect(objectCreateRes.errors).to.have.length(1)
expect(objectCreateRes.errors![0].message).to.eq(
new WorkspaceReadOnlyError().message
)
})
})
})
@@ -0,0 +1,165 @@
import { db } from '@/db/knex'
import { UsersEmitter } from '@/modules/core/events/usersEmitter'
import {
createRandomEmail,
createRandomPassword
} from '@/modules/core/helpers/testHelpers'
import { getServerInfoFactory } from '@/modules/core/repositories/server'
import {
createUserEmailFactory,
ensureNoPrimaryEmailForUserFactory,
findEmailFactory
} from '@/modules/core/repositories/userEmails'
import {
countAdminUsersFactory,
legacyGetUserFactory,
storeUserAclFactory,
storeUserFactory
} from '@/modules/core/repositories/users'
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
import { createUserFactory } from '@/modules/core/services/users/management'
import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories'
import { renderEmail } from '@/modules/emails/services/emailRendering'
import { sendEmail } from '@/modules/emails/services/sending'
import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request'
import {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing'
import { createTestWorkspace } from '@/modules/workspaces/tests/helpers/creation'
import { beforeEachContext } from '@/test/hooks'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import request from 'supertest'
import { Express } from 'express'
import { createPersonalAccessTokenFactory } from '@/modules/core/services/tokens'
import {
storeApiTokenFactory,
storePersonalApiTokenFactory,
storeTokenResourceAccessDefinitionsFactory,
storeTokenScopesFactory
} from '@/modules/core/repositories/tokens'
import { Scopes } from '@speckle/shared'
import { expect } from 'chai'
const getServerInfo = getServerInfoFactory({ db })
const getUser = legacyGetUserFactory({ db })
const requestNewEmailVerification = requestNewEmailVerificationFactory({
findEmail: findEmailFactory({ db }),
getUser,
getServerInfo,
deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }),
renderEmail,
sendEmail
})
const createUserEmail = validateAndCreateUserEmailFactory({
createUserEmail: createUserEmailFactory({ db }),
ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }),
findEmail: findEmailFactory({ db }),
updateEmailInvites: finalizeInvitedServerRegistrationFactory({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
}),
requestNewEmailVerification
})
const findEmail = findEmailFactory({ db })
const createUser = createUserFactory({
getServerInfo,
findEmail,
storeUser: storeUserFactory({ db }),
countAdminUsers: countAdminUsersFactory({ db }),
storeUserAcl: storeUserAclFactory({ db }),
validateAndCreateUserEmail: createUserEmail,
usersEventsEmitter: UsersEmitter.emit
})
const createPersonalAccessToken = createPersonalAccessTokenFactory({
storeApiToken: storeApiTokenFactory({ db }),
storeTokenScopes: storeTokenScopesFactory({ db }),
storeTokenResourceAccessDefinitions: storeTokenResourceAccessDefinitionsFactory({
db
}),
storePersonalApiToken: storePersonalApiTokenFactory({ db })
})
describe('Objects REST @core', () => {
let app: Express
before(async () => {
;({ app } = await beforeEachContext())
})
it('should return an error if the project is read-only', async () => {
const userId = await createUser({
name: 'emails user',
email: createRandomEmail(),
password: createRandomPassword()
})
const user = await getUser(userId)
const workspace = {
name: 'Test Workspace #1',
ownerId: userId,
id: '',
slug: ''
}
await createTestWorkspace(workspace, user, {
addPlan: { name: 'business', status: 'expired' }
})
const project = {
id: '',
name: 'test project',
ownerId: userId,
workspaceId: workspace.id
}
await createTestStream(project as unknown as BasicTestStream, user)
const token = `Bearer ${await createPersonalAccessToken(
user.id,
'test token user A',
[
Scopes.Streams.Read,
Scopes.Streams.Write,
Scopes.Users.Read,
Scopes.Users.Email,
Scopes.Tokens.Write,
Scopes.Tokens.Read,
Scopes.Profile.Read,
Scopes.Profile.Email
]
)}`
const res = await request(app)
.post(`/objects/${project.id}`)
.set('Authorization', token)
.set('Content-type', 'multipart/form-data')
.attach(
'batch1',
Buffer.from(
JSON.stringify({
id: 'e5262a6fb51540974e6d07ac60b7fe5c',
name: 'Rhino Model',
elements: [
{
referencedId: '581a822cdaa5c2972783510d57617f73',
/* eslint-disable camelcase */
speckle_type: 'reference'
}
],
__closure: {
'0086c072ee1fd70ac0a68c067a37e0eb': 3
},
speckleType: 'Speckle.Core.Models.Collection',
speckle_type: 'Speckle.Core.Models.Collection',
applicationId: null,
collectionType: 'rhino model',
totalChildrenCount: 610
}),
'utf8'
)
)
expect(res).to.have.status(403)
})
})
@@ -13,3 +13,9 @@ export type WorkspaceFeatureAccessFunction = (args: {
export type ChangeExpiredTrialWorkspacePlanStatuses = (args: {
numberOfDays: number
}) => Promise<WorkspacePlan[]>
export type GetWorkspacePlanByProjectId = ({
projectId
}: {
projectId: string
}) => Promise<WorkspacePlan | null>
@@ -53,3 +53,9 @@ export class WorkspacePlanDowngradeError extends BaseError {
static code = 'WORKSPACE_PLAN_DOWNGRADE_ERROR'
static statusCode = 400
}
export class WorkspaceReadOnlyError extends BaseError {
static defaultMessage = 'Workspace is read-only'
static code = 'WORKSPACE_READ_ONLY_ERROR'
static statusCode = 403
}
@@ -20,6 +20,7 @@ import {
} from '@/modules/gatekeeper/services/subscriptions'
import {
changeExpiredTrialWorkspacePlanStatusesFactory,
getWorkspacePlanByProjectIdFactory,
getWorkspacePlanFactory,
getWorkspaceSubscriptionsPastBillingCycleEndFactory,
upsertWorkspaceSubscriptionFactory
@@ -28,6 +29,9 @@ import { countWorkspaceRoleWithOptionalProjectRoleFactory } from '@/modules/work
import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe'
import { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations'
import { EventBusEmit, getEventBus } from '@/modules/shared/services/eventBus'
import coreModule from '@/modules/core/index'
import { isProjectReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly'
import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing'
const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
getFeatureFlags()
@@ -166,6 +170,19 @@ const gatekeeperModule: SpeckleModule = {
scheduledTasks.forEach((task) => {
task.stop()
})
},
async finalize() {
coreModule.addHook(
'onCreateObjectRequest',
async function isProjectReadOnly({ projectId }) {
const readOnly = await isProjectReadOnlyFactory({
getWorkspacePlanByProjectId: getWorkspacePlanByProjectIdFactory({
db
})
})({ projectId })
if (readOnly) throw new WorkspaceReadOnlyError()
}
)
}
}
export = gatekeeperModule
@@ -1,3 +1,4 @@
import { Streams } from '@/modules/core/dbSchema'
import {
CheckoutSession,
GetCheckoutSession,
@@ -17,7 +18,11 @@ import {
UpsertTrialWorkspacePlan,
UpsertUnpaidWorkspacePlan
} from '@/modules/gatekeeper/domain/billing'
import { ChangeExpiredTrialWorkspacePlanStatuses } from '@/modules/gatekeeper/domain/operations'
import {
ChangeExpiredTrialWorkspacePlanStatuses,
GetWorkspacePlanByProjectId
} from '@/modules/gatekeeper/domain/operations'
import { Workspaces } from '@/modules/workspacesCore/helpers/db'
import { Knex } from 'knex'
const tables = {
@@ -165,3 +170,20 @@ export const getWorkspaceSubscriptionsPastBillingCycleEndFactory =
.select()
.where('currentBillingCycleEnd', '<', cycleEnd)
}
export const getWorkspacePlanByProjectIdFactory =
({ db }: { db: Knex }): GetWorkspacePlanByProjectId =>
async ({ projectId }) => {
return await tables
.workspacePlans(db)
.select([
'workspace_plans.workspaceId',
'workspace_plans.status',
'workspace_plans.name',
'workspace_plans.createdAt'
])
.innerJoin(Workspaces.name, Workspaces.col.id, 'workspace_plans.workspaceId')
.innerJoin(Streams.name, Streams.col.workspaceId, Workspaces.col.id)
.where({ [Streams.col.id]: projectId })
.first<WorkspacePlan | null>()
}
@@ -1,24 +1,40 @@
import { GetWorkspacePlan } from '@/modules/gatekeeper/domain/billing'
import { GetWorkspacePlan, WorkspacePlan } from '@/modules/gatekeeper/domain/billing'
import { GetWorkspacePlanByProjectId } from '@/modules/gatekeeper/domain/operations'
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)
}
}
export const isWorkspaceReadOnlyFactory =
({ getWorkspacePlan }: { getWorkspacePlan: GetWorkspacePlan }) =>
async ({ workspaceId }: { workspaceId: Workspace['id'] }) => {
const workspacePlan = await getWorkspacePlan({ workspaceId })
// Should never happen
if (!workspacePlan) return true
switch (workspacePlan.status) {
case 'cancelationScheduled':
case 'valid':
case 'trial':
case 'paymentFailed':
return false
case 'expired':
case 'canceled':
return true
default:
throwUncoveredError(workspacePlan)
}
return isWorkspacePlanStatusReadOnly(workspacePlan.status)
}
export const isProjectReadOnlyFactory =
({
getWorkspacePlanByProjectId
}: {
getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId
}) =>
async ({ projectId }: { projectId: string }) => {
const workspacePlan = await getWorkspacePlanByProjectId({ projectId })
if (!workspacePlan) return false // The project is not in a workspace
return isWorkspacePlanStatusReadOnly(workspacePlan.status)
}
@@ -1,5 +1,9 @@
import { GetWorkspacePlan } from '@/modules/gatekeeper/domain/billing'
import { isWorkspaceReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly'
import { GetWorkspacePlanByProjectId } from '@/modules/gatekeeper/domain/operations'
import {
isProjectReadOnlyFactory,
isWorkspaceReadOnlyFactory
} from '@/modules/gatekeeper/services/readOnly'
import { expect } from 'chai'
describe('@gatekeeper readOnly', () => {
@@ -53,4 +57,88 @@ describe('@gatekeeper readOnly', () => {
expect(await isWorkspaceReadOnly({ workspaceId: '' })).to.be.false
})
})
describe('isProjectReadOnlyFactory returns a function that', () => {
it('returns false if project is not in a workspace', async () => {
const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () => null
const isProjectReadOnly = isProjectReadOnlyFactory({
getWorkspacePlanByProjectId
})
expect(await isProjectReadOnly({ projectId: '' })).to.be.false
})
it('returns true if workspace plan status is expired', async () => {
const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () =>
({
status: 'expired'
} as unknown as ReturnType<GetWorkspacePlanByProjectId>)
const isProjectReadOnly = isProjectReadOnlyFactory({
getWorkspacePlanByProjectId
})
expect(await isProjectReadOnly({ projectId: '' })).to.be.true
})
it('returns true if workspace plan status is paymentFailed', async () => {
const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () =>
({
status: 'paymentFailed'
} as unknown as ReturnType<GetWorkspacePlanByProjectId>)
const isProjectReadOnly = isProjectReadOnlyFactory({
getWorkspacePlanByProjectId
})
expect(await isProjectReadOnly({ projectId: '' })).to.be.false
})
it('returns true if workspace plan status is canceled', async () => {
const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () =>
({
status: 'canceled'
} as unknown as ReturnType<GetWorkspacePlanByProjectId>)
const isProjectReadOnly = isProjectReadOnlyFactory({
getWorkspacePlanByProjectId
})
expect(await isProjectReadOnly({ projectId: '' })).to.be.true
})
it('returns false if workspace plan status is trial', async () => {
const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () =>
({
status: 'trial'
} as unknown as ReturnType<GetWorkspacePlanByProjectId>)
const isProjectReadOnly = isProjectReadOnlyFactory({
getWorkspacePlanByProjectId
})
expect(await isProjectReadOnly({ projectId: '' })).to.be.false
})
it('returns false if workspace plan status is valid', async () => {
const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () =>
({
status: 'valid'
} as unknown as ReturnType<GetWorkspacePlanByProjectId>)
const isProjectReadOnly = isProjectReadOnlyFactory({
getWorkspacePlanByProjectId
})
expect(await isProjectReadOnly({ projectId: '' })).to.be.false
})
it('returns false if workspace plan status is cancelationScheduled', async () => {
const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () =>
({
status: 'cancelationScheduled'
} as unknown as ReturnType<GetWorkspacePlanByProjectId>)
const isProjectReadOnly = isProjectReadOnlyFactory({
getWorkspacePlanByProjectId
})
expect(await isProjectReadOnly({ projectId: '' })).to.be.false
})
})
})