Merge branch 'main' into iain/web-2732-observability-for-improved-reliability-workspaces

This commit is contained in:
Iain Sproat
2025-04-16 14:51:59 +01:00
132 changed files with 2753 additions and 1090 deletions
@@ -119,7 +119,7 @@ import {
} from '@/modules/core/services/streams/access'
import { getUserFactory } from '@/modules/core/repositories/users'
import { authorizeResolver } from '@/modules/shared'
import { isNewPaidPlanType, isNewPlanType } from '@/modules/gatekeeper/helpers/plans'
import { isNewPlanType } from '@/modules/gatekeeper/helpers/plans'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags()
@@ -292,7 +292,7 @@ export const onWorkspaceSeatUpdatedFactory =
if (!workspace || !role) return
// Only new plans only rely on seat types
const isNewPlan = workspace.plan && isNewPaidPlanType(workspace.plan.name)
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
if (!isNewPlan) {
return
}
@@ -353,7 +353,7 @@ export const onWorkspaceRoleUpdatedFactory =
if (!workspace) return
// New plans don't do automatic project role assignment
const isNewPlan = workspace.plan && isNewPaidPlanType(workspace.plan.name)
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
if (isNewPlan) {
return
}
@@ -630,10 +630,19 @@ const blockInvalidWorkspaceProjectRoleUpdatesFactory =
let allowedRoles: StreamRoles[]
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
if (isNewPlan) {
const seatRoleMapping = await deps.getWorkspaceSeatTypeToProjectRoleMapping({
workspaceId: project.workspaceId
})
allowedRoles = seatRoleMapping.allowed[seatType]
const workspaceAllowedRoles = (
await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
workspaceId: project.workspaceId
})
).allowed[workspaceRole]
const seatAllowedRoles = (
await deps.getWorkspaceSeatTypeToProjectRoleMapping({
workspaceId: project.workspaceId
})
).allowed[seatType]
allowedRoles = Array.from(
new Set(workspaceAllowedRoles).intersection(new Set(seatAllowedRoles))
)
} else {
const roleMapping = await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
workspaceId: project.workspaceId
@@ -1,5 +1,8 @@
import { db } from '@/db/knex'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import {
Resolvers,
TokenResourceIdentifierType
} from '@/modules/core/graph/generated/graphql'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import {
updateProjectFactory,
@@ -205,6 +208,7 @@ import {
getWorkspaceRolesAndSeatsFactory,
getWorkspaceUserSeatFactory
} from '@/modules/gatekeeper/repositories/workspaceSeat'
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
import {
mapAuthToServerError,
throwIfAuthNotOk
@@ -317,6 +321,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
if (!workspace) {
throw new WorkspaceNotFoundError()
}
await authorizeResolver(
ctx.userId,
workspace.id,
@@ -369,13 +374,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
},
ProjectInviteMutations: {
async createForWorkspace(_parent, args, ctx) {
const projectId = args.projectId
await authorizeResolver(
ctx.userId,
projectId,
Roles.Stream.Owner,
ctx.resourceAccessRules
)
const { projectId } = args
const inviteCount = args.inputs.length
if (inviteCount > 10 && ctx.role !== Roles.Server.Admin) {
@@ -384,12 +383,26 @@ export = FF_WORKSPACES_MODULE_ENABLED
)
}
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const logger = ctx.log.child({
projectId,
streamId: projectId, //legacy
inviteCount
})
const canInvite = await ctx.authPolicies.project.canInvite({
userId: ctx.userId,
projectId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const createProjectInvite = createProjectInviteFactory({
createAndSendInvite: buildCreateAndSendServerOrProjectInvite(),
getStream
@@ -1037,12 +1050,19 @@ export = FF_WORKSPACES_MODULE_ENABLED
input: { inviteId, workspaceId }
} = args
await authorizeResolver(
ctx.userId!,
workspaceId,
Roles.Workspace.Admin,
ctx.resourceAccessRules
)
throwIfResourceAccessNotAllowed({
resourceId: workspaceId,
resourceType: TokenResourceIdentifierType.Workspace,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.workspace.canInvite({
userId: ctx.userId,
workspaceId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const logger = ctx.log.child({
workspaceId,
@@ -1083,11 +1103,26 @@ export = FF_WORKSPACES_MODULE_ENABLED
return true
},
create: async (_parent, args, ctx) => {
const workspaceId = args.workspaceId
const { workspaceId } = args
throwIfResourceAccessNotAllowed({
resourceId: workspaceId,
resourceType: TokenResourceIdentifierType.Workspace,
resourceAccessRules: ctx.resourceAccessRules
})
const logger = ctx.log.child({
workspaceId
})
const canInvite = await ctx.authPolicies.workspace.canInvite({
userId: ctx.userId,
workspaceId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const createInvite = createWorkspaceInviteFactory({
createAndSendInvite: buildCreateAndSendWorkspaceInvite()
})
@@ -1109,7 +1144,8 @@ export = FF_WORKSPACES_MODULE_ENABLED
return ctx.loaders.workspaces!.getWorkspace.load(workspaceId)
},
batchCreate: async (_parent, args, ctx) => {
const workspaceId = args.workspaceId
const { workspaceId } = args
const inviteCount = args.input.length
if (inviteCount > 10 && ctx.role !== Roles.Server.Admin) {
throw new InviteCreateValidationError(
@@ -1117,11 +1153,25 @@ export = FF_WORKSPACES_MODULE_ENABLED
)
}
throwIfResourceAccessNotAllowed({
resourceId: workspaceId,
resourceType: TokenResourceIdentifierType.Workspace,
resourceAccessRules: ctx.resourceAccessRules
})
const logger = ctx.log.child({
workspaceId,
inviteCount
})
const canInvite = await ctx.authPolicies.workspace.canInvite({
userId: ctx.userId,
workspaceId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const createInvite = createWorkspaceInviteFactory({
createAndSendInvite: buildCreateAndSendWorkspaceInvite()
})
@@ -1222,14 +1272,21 @@ export = FF_WORKSPACES_MODULE_ENABLED
return true
},
cancel: async (_parent, args, ctx) => {
const workspaceId = args.workspaceId
const inviteId = args.inviteId
await authorizeResolver(
ctx.userId,
workspaceId,
Roles.Workspace.Admin,
ctx.resourceAccessRules
)
const { workspaceId, inviteId } = args
throwIfResourceAccessNotAllowed({
resourceId: workspaceId,
resourceType: TokenResourceIdentifierType.Workspace,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.workspace.canInvite({
userId: ctx.userId,
workspaceId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const logger = ctx.log.child({
workspaceId,
@@ -1881,12 +1938,19 @@ export = FF_WORKSPACES_MODULE_ENABLED
if (!requestedWorkspaceId) return false
if (payload.workspaceId !== requestedWorkspaceId) return false
await authorizeResolver(
ctx.userId!,
payload.workspaceId,
Roles.Workspace.Guest,
ctx.resourceAccessRules
)
// TODO: Subs dont clear until actual response!! formatResponse/formatError, doesn't kick in
// if this handler returns false
const projectId = payload.workspaceProjectsUpdated.projectId
const canGetMessage =
await ctx.authPolicies.workspace.canReceiveProjectsUpdatedMessage({
userId: ctx.userId,
projectId,
workspaceId: requestedWorkspaceId
})
if (canGetMessage.isErr) {
return false
}
return true
}
@@ -46,7 +46,7 @@ import {
GetWorkspaceWithPlan,
WorkspaceSeatType
} from '@/modules/gatekeeper/domain/billing'
import { isNewPaidPlanType } from '@/modules/gatekeeper/helpers/plans'
import { isNewPlanType } from '@/modules/gatekeeper/helpers/plans'
import { NotImplementedError } from '@/modules/shared/errors'
import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
import { userEmailsCompliantWithWorkspaceDomains } from '@/modules/workspaces/domain/logic'
@@ -226,32 +226,38 @@ export const getWorkspaceRoleToDefaultProjectRoleMappingFactory =
throw new WorkspaceNotFoundError()
}
const isNewPlan = workspace.plan && isNewPaidPlanType(workspace.plan.name)
if (isNewPlan) {
throw new NotImplementedError(
'This function is not supported for this workspace plan'
)
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
const allowed = {
[Roles.Workspace.Guest]: [Roles.Stream.Reviewer, Roles.Stream.Contributor],
[Roles.Workspace.Member]: [
Roles.Stream.Reviewer,
Roles.Stream.Contributor,
Roles.Stream.Owner
],
[Roles.Workspace.Admin]: [
Roles.Stream.Reviewer,
Roles.Stream.Contributor,
Roles.Stream.Owner
]
}
if (isNewPlan)
return {
default: {
[Roles.Workspace.Guest]: null,
[Roles.Workspace.Member]: null,
[Roles.Workspace.Admin]: null
},
allowed
}
return {
default: {
[Roles.Workspace.Guest]: null,
[Roles.Workspace.Member]: Roles.Stream.Reviewer,
[Roles.Workspace.Admin]: Roles.Stream.Owner
},
allowed: {
[Roles.Workspace.Guest]: [Roles.Stream.Reviewer, Roles.Stream.Contributor],
[Roles.Workspace.Member]: [
Roles.Stream.Reviewer,
Roles.Stream.Contributor,
Roles.Stream.Owner
],
[Roles.Workspace.Admin]: [
Roles.Stream.Reviewer,
Roles.Stream.Contributor,
Roles.Stream.Owner
]
}
allowed
}
}
@@ -267,7 +273,7 @@ export const getWorkspaceSeatTypeToProjectRoleMappingFactory =
throw new WorkspaceNotFoundError()
}
const isNewPlan = workspace.plan && isNewPaidPlanType(workspace.plan.name)
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
if (!isNewPlan) {
throw new NotImplementedError(
'This function is not supported for this workspace plan'
@@ -186,9 +186,7 @@ describe('Workspaces Invites GQL', () => {
}
})
expect(res).to.haveGraphQLErrors(
'Attempting to invite into a non-existant workspace'
)
expect(res).to.haveGraphQLErrors('You do not have access to the workspace')
expect(res.data?.workspaceMutations?.invites?.create).to.not.be.ok
})
@@ -226,7 +224,9 @@ describe('Workspaces Invites GQL', () => {
}
})
expect(res).to.haveGraphQLErrors('You are not authorized')
expect(res).to.haveGraphQLErrors(
'You do not have enough permissions in the workspace to perform this action'
)
expect(res.data?.workspaceMutations?.invites?.create).to.not.be.ok
})
@@ -269,7 +269,9 @@ describe('Workspaces Invites GQL', () => {
}))
})
expect(res).to.haveGraphQLErrors('You are not authorized')
expect(res).to.haveGraphQLErrors(
'You do not have enough permissions in the workspace to perform this action'
)
expect(res.data?.workspaceMutations?.invites?.batchCreate).to.not.be.ok
})
@@ -739,7 +741,9 @@ describe('Workspaces Invites GQL', () => {
}
})
expect(res).to.haveGraphQLErrors('You are not authorized')
expect(res).to.haveGraphQLErrors(
'You do not have enough permissions in the workspace to perform this action'
)
expect(res.data?.workspaceMutations?.invites?.cancel).to.not.be.ok
const invite = await findInviteFactory({ db })({
@@ -49,10 +49,7 @@ import {
createRandomEmail,
createRandomString
} from '@/modules/core/helpers/testHelpers'
import {
getWorkspaceFactory,
getWorkspaceRoleForUserFactory
} from '@/modules/workspaces/repositories/workspaces'
import { getWorkspaceRoleForUserFactory } from '@/modules/workspaces/repositories/workspaces'
import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
@@ -489,14 +486,20 @@ describe('Workspaces GQL CRUD', () => {
})
it('should return workspace team projectRoles', async () => {
const createRes = await apollo.execute(CreateWorkspaceDocument, {
input: { name: createRandomString() }
// create workspace w/ infinite limits (otherwise test fails)
const workspace: BasicTestWorkspace = {
name: createRandomString(),
id: '',
ownerId: '',
slug: ''
}
await createTestWorkspace(workspace, testMemberUser, {
addPlan: {
name: 'teamUnlimited',
status: 'valid'
}
})
expect(createRes).to.not.haveGraphQLErrors()
const workspaceId = createRes.data!.workspaceMutations.create.id
const workspace = (await getWorkspaceFactory({ db })({
workspaceId
})) as unknown as BasicTestWorkspace
const workspaceId = workspace.id
const member = {
id: createRandomString(),
@@ -1082,7 +1085,7 @@ describe('Workspaces GQL CRUD', () => {
})
expect(deleteRes).to.not.haveGraphQLErrors()
expect(getRes).to.haveGraphQLErrors('Workspace not found')
expect(getRes).to.haveGraphQLErrors({ code: WorkspaceNotFoundError.code })
})
it('should throw if non-workspace-admin triggers delete', async () => {