feat(server): web 3485 prevent accounts from creating new workspaces (#4913)

* feat(shared): rename user workspaces loader

* feat(gatekeeper): intoduce the enterprise plan

* chore(server): remove more "magic strings"

* refactor(shared): extract user is workspace admin to an auth fragment

* feat(shared): add can createWorkspacePolicy

* feat(workspaces): WIP block workspace creation

* feat(server): add can create workspace checks

* feat(workspaces): enforce canCreateWorkspace policy on the workspace
creation mutation

* feat(shared): allow workspace admins and guests to create workspaces
even if they are part of an exclusive workspace

* test(shared): use test fake properly

* fix(server): eligble workspace typing fixes

* test(shared): fix more workspace fakes

* fix(workspacesCore): add missing loader

* fix(shared): use proper exhaustive switch cases, they stop bugs from
happening

* feat(shared): introduce workspacePlanHasAccessToFeature function with tests

* chore(workspaces): fix more PR comments

* fix(workspaces): naming

* fix(workspaces): some more
This commit is contained in:
Gergő Jedlicska
2025-06-18 08:58:26 +01:00
committed by GitHub
parent 2be1592341
commit 4a2d85d68c
58 changed files with 1207 additions and 164 deletions
+28
View File
@@ -12,5 +12,33 @@
"skipFiles": ["<node_internals>/**"],
"env": {},
"stop_on_entry": false
},
{
"adapter": "JavaScript",
"label": "ZED yarn test (server)",
"request": "launch",
"console": "integratedTerminal",
"program": "test",
"runtimeExecutable": "yarn",
"args": [],
"type": "pwa-node",
"cwd": "$ZED_WORKTREE_ROOT/packages/server",
"skipFiles": ["<node_internals>/**"],
"env": {},
"stop_on_entry": false
},
{
"adapter": "JavaScript",
"label": "ZED yarn test (shared)",
"request": "launch",
"console": "integratedTerminal",
"program": "test",
"runtimeExecutable": "yarn",
"args": ["-t", "forbids creation for users eligible"],
"type": "pwa-node",
"cwd": "$ZED_WORKTREE_ROOT/packages/shared",
"skipFiles": ["<node_internals>/**"],
"env": {},
"stop_on_entry": false
}
]
@@ -3103,6 +3103,7 @@ export type Role = {
export type RootPermissionChecks = {
__typename?: 'RootPermissionChecks';
canCreatePersonalProject: PermissionCheckResult;
canCreateWorkspace: PermissionCheckResult;
};
/** Available scopes. */
@@ -8817,6 +8818,7 @@ export type RoleFieldArgs = {
}
export type RootPermissionChecksFieldArgs = {
canCreatePersonalProject: {},
canCreateWorkspace: {},
}
export type ScopeFieldArgs = {
description: {},
@@ -20,6 +20,7 @@ type ProjectPermissionChecks {
type RootPermissionChecks {
canCreatePersonalProject: PermissionCheckResult!
canCreateWorkspace: PermissionCheckResult!
}
extend type User {
@@ -3126,6 +3126,7 @@ export type Role = {
export type RootPermissionChecks = {
__typename?: 'RootPermissionChecks';
canCreatePersonalProject: PermissionCheckResult;
canCreateWorkspace: PermissionCheckResult;
};
/** Available scopes. */
@@ -7080,6 +7081,7 @@ export type RoleResolvers<ContextType = GraphQLContext, ParentType extends Resol
export type RootPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['RootPermissionChecks'] = ResolversParentTypes['RootPermissionChecks']> = {
canCreatePersonalProject?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateWorkspace?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -164,6 +164,12 @@ export default {
}
)
return Authz.toGraphqlResult(canCreatePersonalProject)
},
canCreateWorkspace: async (_parent, _args, ctx) => {
const policyResult = await ctx.authPolicies.workspace.canCreateWorkspace({
userId: ctx.userId
})
return Authz.toGraphqlResult(policyResult)
}
}
} as Resolvers
@@ -3106,6 +3106,7 @@ export type Role = {
export type RootPermissionChecks = {
__typename?: 'RootPermissionChecks';
canCreatePersonalProject: PermissionCheckResult;
canCreateWorkspace: PermissionCheckResult;
};
/** Available scopes. */
@@ -42,6 +42,7 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
case Authz.CommentNoAccessError.code:
case Authz.ProjectNotEnoughPermissionsError.code:
case Authz.WorkspaceNoFeatureAccessError.code:
case Authz.EligibleForExclusiveWorkspaceError.code:
return new ForbiddenError(e.message)
case Authz.WorkspaceSsoSessionNoAccessError.code:
throw new SsoSessionMissingOrExpiredError(e.message, {
@@ -8,9 +8,14 @@ import {
getUserSsoSessionFactory,
getWorkspaceSsoProviderRecordFactory
} from '@/modules/workspaces/repositories/sso'
import { getWorkspaceRoleForUserFactory } from '@/modules/workspaces/repositories/workspaces'
import {
getUserEligibleWorkspacesFactory,
getWorkspaceRoleForUserFactory
} from '@/modules/workspaces/repositories/workspaces'
import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects'
import { getWorkspaceModelCountFactory } from '@/modules/workspaces/services/workspaceLimits'
import { getUsersCurrentAndEligibleToBecomeAMemberWorkspaces } from '@/modules/workspaces/services/retrieval'
import { findEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails'
// TODO: Move everything to use dataLoaders
export default defineModuleLoaders(async () => {
@@ -71,6 +76,12 @@ export default defineModuleLoaders(async () => {
getWorkspacePlan: async ({ workspaceId }) => {
return await getWorkspacePlan({ workspaceId })
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({ userId }) => {
return await getUsersCurrentAndEligibleToBecomeAMemberWorkspaces({
findEmailsByUserId: findEmailsByUserIdFactory({ db }),
getUserEligibleWorkspaces: getUserEligibleWorkspacesFactory({ db })
})({ userId })
},
getWorkspaceLimits: async ({ workspaceId }, { dataLoaders }) => {
return await dataLoaders.gatekeeper!.getWorkspaceLimits.load(workspaceId)
}
@@ -50,6 +50,7 @@ export const toLimitedWorkspace = (workspace: Workspace): LimitedWorkspace => {
name: workspace.name,
description: workspace.description,
logo: workspace.logo,
discoverabilityAutoJoinEnabled: workspace.discoverabilityAutoJoinEnabled
discoverabilityAutoJoinEnabled: workspace.discoverabilityAutoJoinEnabled,
isExclusive: workspace.isExclusive
}
}
@@ -48,6 +48,14 @@ export type GetUserDiscoverableWorkspaces = (args: {
userId: string
}) => Promise<LimitedWorkspace[]>
// adding optional role to each workspace
export type EligibleWorkspace = LimitedWorkspace & { role?: WorkspaceRoles }[]
export type GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces = (args: {
domains: string[]
userId: string
}) => Promise<EligibleWorkspace[]>
export type GetWorkspace = (args: {
workspaceId: string
userId?: string
@@ -611,6 +611,12 @@ export = FF_WORKSPACES_MODULE_ENABLED
enableDomainDiscoverabilityForDomain
} = args.input
// Check if user can create workspace
const canCreate = await context.authPolicies.workspace.canCreateWorkspace({
userId: context.userId
})
throwIfAuthNotOk(canCreate)
const logger = context.log
return await asOperation(
@@ -13,12 +13,14 @@ import {
DeleteWorkspace,
DeleteWorkspaceDomain,
DeleteWorkspaceRole,
EligibleWorkspace,
GetAllWorkspaces,
GetPaginatedWorkspaceProjects,
GetPaginatedWorkspaceProjectsArgs,
GetPaginatedWorkspaceProjectsItems,
GetPaginatedWorkspaceProjectsTotalCount,
GetUserDiscoverableWorkspaces,
GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces,
GetUserIdsWithRoleInWorkspace,
GetWorkspace,
GetWorkspaceBySlug,
@@ -62,6 +64,7 @@ import {
import {
knex,
ServerAcl,
ServerInvites,
StreamAcl,
Streams,
UserEmails,
@@ -96,6 +99,39 @@ const tables = {
db<WorkspaceJoinRequest>('workspace_join_requests')
}
export const getUserEligibleWorkspacesFactory =
({ db }: { db: Knex }): GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces =>
async ({ userId, domains }) => {
const q = tables
.workspaces(db)
.distinctOn(Workspaces.col.id)
.select<EligibleWorkspace[]>([...Workspaces.cols, DbWorkspaceAcl.col.role])
.joinRaw(
`left join ${DbWorkspaceAcl.name}
on ${Workspaces.col.id} = ${DbWorkspaceAcl.name}."${DbWorkspaceAcl.withoutTablePrefix.col.workspaceId}"
and ${DbWorkspaceAcl.name}."${DbWorkspaceAcl.withoutTablePrefix.col.userId}" = '${userId}'`
)
.joinRaw(
`left join ${ServerInvites.name}
on ${Workspaces.col.id} = ${ServerInvites.col.resource} ->> 'resourceId'
and ${ServerInvites.col.target} = '@${userId}'`
)
.leftJoin(
WorkspaceDomains.name,
WorkspaceDomains.col.workspaceId,
Workspaces.col.id
)
.whereNotNull(DbWorkspaceAcl.col.userId)
.orWhereNotNull(ServerInvites.col.target)
if (domains.length)
q.orWhere(function () {
this.where(Workspaces.col.discoverabilityEnabled, true)
this.whereIn(WorkspaceDomains.col.domain, domains)
})
const items = await q
return items
}
export const getUserDiscoverableWorkspacesFactory =
({ db }: { db: Knex }): GetUserDiscoverableWorkspaces =>
async ({ domains, userId }) => {
@@ -112,6 +148,7 @@ export const getUserDiscoverableWorkspacesFactory =
'description',
'logo',
'discoverabilityAutoJoinEnabled',
'isExclusive',
tables
.workspacesAcl(db)
.select(knex.raw('count(*)::integer'))
@@ -312,7 +349,8 @@ export const upsertWorkspaceFactory =
'discoverabilityEnabled',
'discoverabilityAutoJoinEnabled',
'defaultSeatType',
'isEmbedSpeckleBrandingHidden'
'isEmbedSpeckleBrandingHidden',
'isExclusive'
])
}
@@ -167,7 +167,8 @@ export const createWorkspaceFactory =
discoverabilityEnabled: false,
discoverabilityAutoJoinEnabled: false,
isEmbedSpeckleBrandingHidden: false,
defaultSeatType: null
defaultSeatType: null,
isExclusive: false
} satisfies Workspace
await upsertWorkspace({ workspace })
@@ -1,6 +1,8 @@
import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
import { UserEmail } from '@/modules/core/domain/userEmails/types'
import {
GetUserDiscoverableWorkspaces,
GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces,
GetWorkspaceRolesForUser,
GetWorkspaces
} from '@/modules/workspaces/domain/operations'
@@ -10,6 +12,9 @@ type GetDiscoverableWorkspaceForUserArgs = {
userId: string
}
const getUserVerifiedDomains = (userEmails: UserEmail[]): string[] =>
userEmails.filter((email) => email.verified).map((email) => email.email.split('@')[1])
export const getDiscoverableWorkspacesForUserFactory =
({
findEmailsByUserId,
@@ -22,9 +27,7 @@ export const getDiscoverableWorkspacesForUserFactory =
userId
}: GetDiscoverableWorkspaceForUserArgs): Promise<LimitedWorkspace[]> => {
const userEmails = await findEmailsByUserId({ userId })
const userVerifiedDomains = userEmails
.filter((email) => email.verified)
.map((email) => email.email.split('@')[1])
const userVerifiedDomains = getUserVerifiedDomains(userEmails)
const workspaces = await getDiscoverableWorkspaces({
domains: userVerifiedDomains,
userId
@@ -33,6 +36,27 @@ export const getDiscoverableWorkspacesForUserFactory =
return workspaces
}
export const getUsersCurrentAndEligibleToBecomeAMemberWorkspaces =
({
findEmailsByUserId,
getUserEligibleWorkspaces
}: {
findEmailsByUserId: FindEmailsByUserId
getUserEligibleWorkspaces: GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces
}) =>
async ({
userId
}: GetDiscoverableWorkspaceForUserArgs): Promise<LimitedWorkspace[]> => {
const userEmails = await findEmailsByUserId({ userId })
const userVerifiedDomains = getUserVerifiedDomains(userEmails)
const workspaces = await getUserEligibleWorkspaces({
domains: userVerifiedDomains,
userId
})
return workspaces
}
type GetWorkspacesForUserArgs = {
userId: string
completed?: boolean
@@ -7,13 +7,14 @@ import {
getWorkspaceRolesFactory,
getWorkspaceRolesForUserFactory,
deleteWorkspaceFactory,
storeWorkspaceDomainFactory,
getUserDiscoverableWorkspacesFactory,
getUserEligibleWorkspacesFactory,
getWorkspaceWithDomainsFactory,
countWorkspaceRoleWithOptionalProjectRoleFactory,
getWorkspaceCollaboratorsFactory,
getWorkspaceBySlugFactory,
getWorkspacesFactory
getWorkspacesFactory,
storeWorkspaceDomainFactory
} from '@/modules/workspaces/repositories/workspaces'
import db from '@/db/knex'
import cryptoRandomString from 'crypto-random-string'
@@ -52,7 +53,7 @@ import {
import { omit } from 'lodash'
import { createAndStoreTestWorkspaceFactory } from '@/test/speckle-helpers/workspaces'
import { WorkspaceJoinRequests } from '@/modules/workspacesCore/helpers/db'
import { insertInviteAndDeleteOldFactory } from '@/modules/serverinvites/repositories/serverInvites'
const getWorkspace = getWorkspaceFactory({ db })
const getWorkspaces = getWorkspacesFactory({ db })
const getWorkspaceBySlug = getWorkspaceBySlugFactory({ db })
@@ -67,9 +68,11 @@ const storeWorkspaceDomain = storeWorkspaceDomainFactory({ db })
const createUserEmail = createUserEmailFactory({ db })
const updateUserEmail = updateUserEmailFactory({ db })
const getUserDiscoverableWorkspaces = getUserDiscoverableWorkspacesFactory({ db })
const getUserEligibleWorkspaces = getUserEligibleWorkspacesFactory({ db })
const upsertProjectRole = upsertProjectRoleFactory({ db })
const grantStreamPermissions = grantStreamPermissionsFactory({ db })
const upsertWorkspace = upsertWorkspaceFactory({ db })
const insertInviteAndDeleteOld = insertInviteAndDeleteOldFactory({ db })
const createAndStoreTestWorkspace = createAndStoreTestWorkspaceFactory({
upsertWorkspace
@@ -1317,4 +1320,220 @@ describe('Workspace repositories', () => {
expect(count).to.equal(1)
})
})
describe('getUserEligibleWorkspacesFactory creates a function, that', () => {
it('returns workspaces where user is a member', async () => {
const testUser1 = buildBasicTestUser()
await createTestUsers([testUser1])
const workspace1 = buildBasicTestWorkspace({
name: 'Workspace 1',
description: 'User is member'
})
await createTestWorkspace(workspace1, testUser1)
const workspaces = await getUserEligibleWorkspaces({
userId: testUser1.id,
domains: []
})
const workspaceIds = workspaces.map((w) => w.id)
expect(workspaceIds).deep.equalInAnyOrder([workspace1.id])
})
it('returns workspaces where user has an invite', async () => {
const testUser1 = buildBasicTestUser()
const testUser2 = buildBasicTestUser()
await createTestUsers([testUser1, testUser2])
const workspace1 = buildBasicTestWorkspace({
name: 'Workspace 1',
description: 'User is member'
})
await createTestWorkspace(workspace1, testUser1)
await insertInviteAndDeleteOld({
id: createRandomString(),
inviterId: testUser1.id,
message: '',
target: `@${testUser2.id}`,
token: createRandomString(),
resource: {
role: 'workspace:member',
primary: true,
resourceId: workspace1.id,
resourceType: 'workspace',
secondaryResourceRoles: {}
}
})
const workspaces = await getUserEligibleWorkspaces({
userId: testUser2.id,
domains: []
})
const workspaceIds = workspaces.map((w) => w.id)
expect(workspaceIds).deep.equalInAnyOrder([workspace1.id])
})
it('returns empty if user not eligible', async () => {
const testUser1 = buildBasicTestUser()
const testUser2 = buildBasicTestUser()
await createTestUsers([testUser1, testUser2])
const workspace1 = buildBasicTestWorkspace({
name: 'Workspace 1',
description: 'User is member'
})
await createTestWorkspace(workspace1, testUser1)
const workspaces = await getUserEligibleWorkspaces({
userId: testUser2.id,
domains: []
})
const workspaceIds = workspaces.map((w) => w.id)
expect(workspaceIds).to.have.length(0)
})
it('returns discoverable workspaces with matching verified domains', async () => {
const domain = `${createRandomString()}.com`
const testUser1 = buildBasicTestUser({
email: `${createRandomString()}@${domain}`,
verified: true
})
const testUser2 = buildBasicTestUser({
email: `${createRandomString()}@${domain}`,
verified: true
})
await createTestUsers([testUser1, testUser2])
const workspace1 = buildBasicTestWorkspace({
name: 'Workspace 1',
description: 'User is member',
discoverabilityEnabled: true
})
const workspace2 = buildBasicTestWorkspace({
name: 'Workspace 2',
description: 'User is member'
})
await createTestWorkspace(workspace1, testUser1, { domain })
await createTestWorkspace(workspace2, testUser2)
const workspaces = await getUserEligibleWorkspaces({
userId: testUser2.id,
domains: [domain]
})
const workspaceIds = workspaces.map((w) => w.id)
expect(workspaceIds).deep.equalInAnyOrder([workspace1.id, workspace2.id])
})
it('does not return discoverable workspaces with matching unverified domains', async () => {
const domain = `${createRandomString()}.com`
const testUser1 = buildBasicTestUser({
email: `${createRandomString()}@${domain}`,
verified: true
})
const testUser2 = buildBasicTestUser({
email: `${createRandomString()}@${domain}`,
verified: false
})
await createTestUsers([testUser1, testUser2])
const workspace1 = buildBasicTestWorkspace({
name: 'Workspace 2',
description: 'User is member',
discoverabilityEnabled: false
})
await createTestWorkspace(workspace1, testUser1, { domain })
const workspaces = await getUserEligibleWorkspaces({
userId: testUser2.id,
domains: [domain]
})
const workspaceIds = workspaces.map((w) => w.id)
expect(workspaceIds).deep.equalInAnyOrder([])
})
it('does not return non discoverable workspaces with matching verified domains', async () => {
const domain = `${createRandomString()}.com`
const testUser1 = buildBasicTestUser({
email: `${createRandomString()}@${domain}`,
verified: true
})
const testUser2 = buildBasicTestUser({
email: `${createRandomString()}@${domain}`,
verified: true
})
await createTestUsers([testUser1, testUser2])
const workspace1 = buildBasicTestWorkspace({
name: 'Workspace 2',
description: 'User is member',
discoverabilityEnabled: false
})
await createTestWorkspace(workspace1, testUser1, { domain })
const workspaces = await getUserEligibleWorkspaces({
userId: testUser2.id,
domains: [domain]
})
const workspaceIds = workspaces.map((w) => w.id)
expect(workspaceIds).deep.equalInAnyOrder([])
})
it('does not return workspaces without matching domains when not member/invited', async () => {
const domain1 = `${createRandomString()}.com`
const testUser1 = buildBasicTestUser({
email: `${createRandomString()}@${domain1}`,
verified: true
})
const domain2 = `${createRandomString()}.com`
const testUser2 = buildBasicTestUser({
email: `${createRandomString()}@${domain2}.com`,
verified: true
})
await createTestUsers([testUser1, testUser2])
const workspace1 = buildBasicTestWorkspace({
name: 'Workspace 2',
description: 'User is member',
discoverabilityEnabled: true
})
await createTestWorkspace(workspace1, testUser1, { domain: domain1 })
const workspaces = await getUserEligibleWorkspaces({
userId: testUser2.id,
domains: [domain2]
})
const workspaceIds = workspaces.map((w) => w.id)
expect(workspaceIds).deep.equalInAnyOrder([])
})
it('returns unique workspaces when user has multiple access paths', async () => {
const domain1 = `${createRandomString()}.com`
const testUser1 = buildBasicTestUser({
email: `${createRandomString()}@${domain1}`,
verified: true
})
await createTestUsers([testUser1])
const workspace1 = buildBasicTestWorkspace({
name: 'Workspace 2',
description: 'User is member',
discoverabilityEnabled: true
})
await createTestWorkspace(workspace1, testUser1, { domain: domain1 })
const workspaces = await getUserEligibleWorkspaces({
userId: testUser1.id,
domains: ['example.com']
})
const workspaceIds = workspaces.map((w) => w.id)
expect(workspaceIds).deep.equalInAnyOrder([workspace1.id])
})
})
})
@@ -258,6 +258,7 @@ describe('Workspace services', () => {
createdAt: new Date(),
updatedAt: new Date(),
logo: null,
isExclusive: false,
discoverabilityEnabled: false,
discoverabilityAutoJoinEnabled: false,
domainBasedMembershipProtectionEnabled: false,
@@ -1147,6 +1148,7 @@ describe('Workspace role services', () => {
name: cryptoRandomString({ length: 10 }),
slug: cryptoRandomString({ length: 10 }),
logo: null,
isExclusive: false,
createdAt: new Date(),
updatedAt: new Date(),
description: null,
@@ -1191,6 +1193,7 @@ describe('Workspace role services', () => {
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
isExclusive: false,
description: null,
discoverabilityEnabled: false,
discoverabilityAutoJoinEnabled: false,
@@ -381,6 +381,7 @@ describe('Workspace SSO services', () => {
name: '',
description: '',
logo: null,
isExclusive: false,
domainBasedMembershipProtectionEnabled: false,
discoverabilityEnabled: false,
discoverabilityAutoJoinEnabled: false,
@@ -14,6 +14,9 @@ export default defineModuleLoaders(() => ({
getWorkspaceSsoProvider: async () => {
throw new LoaderUnsupportedError()
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async () => {
throw new LoaderUnsupportedError()
},
getWorkspaceSeat: async () => {
throw new LoaderUnsupportedError()
},
@@ -33,11 +33,18 @@ export type Workspace = {
defaultSeatType: WorkspaceSeatType | null
// TODO: Create new table/structure if embeds get more workspace-level configuration
isEmbedSpeckleBrandingHidden: boolean
isExclusive: boolean
}
export type LimitedWorkspace = Pick<
Workspace,
'id' | 'slug' | 'name' | 'description' | 'logo' | 'discoverabilityAutoJoinEnabled'
| 'id'
| 'slug'
| 'name'
| 'description'
| 'logo'
| 'discoverabilityAutoJoinEnabled'
| 'isExclusive'
>
export type WorkspaceWithDomains = Workspace & { domains: WorkspaceDomain[] }
@@ -12,7 +12,8 @@ export const Workspaces = buildTableHelper('workspaces', [
'discoverabilityEnabled',
'discoverabilityAutoJoinEnabled',
'defaultSeatType',
'isEmbedSpeckleBrandingHidden'
'isEmbedSpeckleBrandingHidden',
'isExclusive'
])
export const WorkspaceAcl = buildTableHelper('workspace_acl', [
@@ -0,0 +1,13 @@
import { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('workspaces', (table) => {
table.boolean('isExclusive').notNullable().defaultTo(false)
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('workspaces', (table) => {
table.dropColumn('isExclusive')
})
}
@@ -3107,6 +3107,7 @@ export type Role = {
export type RootPermissionChecks = {
__typename?: 'RootPermissionChecks';
canCreatePersonalProject: PermissionCheckResult;
canCreateWorkspace: PermissionCheckResult;
};
/** Available scopes. */
@@ -13,6 +13,7 @@ export const createAndStoreTestWorkspaceFactory =
updatedAt: new Date(),
description: null,
logo: null,
isExclusive: false,
domainBasedMembershipProtectionEnabled: false,
discoverabilityEnabled: false,
discoverabilityAutoJoinEnabled: false,
@@ -94,6 +94,15 @@ export const WorkspaceNotEnoughPermissionsError = defineAuthError({
message: 'You do not have enough permissions in the workspace to perform this action'
})
export const EligibleForExclusiveWorkspaceError = defineAuthError({
code: 'UserEligibleForExclusiveWorkspace',
message:
'Cannot create workspace: ' +
'You are a member or eligible to become a member of an exclusive workspace. ' +
'This is due to you having received an invite to the workspace ' +
'or having a matching verified email.'
})
export const WorkspaceReadOnlyError = defineAuthError({
code: 'WorkspaceReadOnly',
message: 'The workspace is in a read only mode, upgrade your plan to unlock it'
@@ -10,6 +10,7 @@ import type {
import type {
GetAdminOverrideEnabled,
GetEnv,
GetUserWorkspaces,
GetWorkspace,
GetWorkspaceLimits,
GetWorkspaceModelCount,
@@ -60,6 +61,8 @@ export const AuthCheckContextLoaderKeys = <const>{
getProjectModelCount: 'getProjectModelCount',
getServerRole: 'getServerRole',
getWorkspace: 'getWorkspace',
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces:
'getUsersCurrentAndEligibleToBecomeAMemberWorkspaces',
getWorkspaceRole: 'getWorkspaceRole',
getWorkspaceSeat: 'getWorkspaceSeat',
getWorkspaceModelCount: 'getWorkspaceModelCount',
@@ -88,6 +91,7 @@ export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{
getProjectModelCount: GetProjectModelCount
getServerRole: GetServerRole
getWorkspace: GetWorkspace
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: GetUserWorkspaces
getWorkspaceRole: GetWorkspaceRole
getWorkspaceLimits: GetWorkspaceLimits
getWorkspacePlan: GetWorkspacePlan
@@ -7,6 +7,8 @@ import { Workspace, WorkspaceSsoProvider, WorkspaceSsoSession } from './types.js
export type GetWorkspace = (args: WorkspaceContext) => Promise<Workspace | null>
export type GetUserWorkspaces = (args: UserContext) => Promise<Workspace[]>
export type GetWorkspaceRole = (
args: UserContext & WorkspaceContext
) => Promise<WorkspaceRoles | null>
@@ -1,6 +1,10 @@
import { WorkspaceRoles } from '../../../core/constants.js'
export type Workspace = {
id: string
slug: string
isExclusive: boolean
role?: WorkspaceRoles | null
}
export type WorkspaceSsoProvider = {
@@ -19,7 +19,7 @@ import {
WorkspaceSsoSessionNoAccessError
} from '../domain/authErrors.js'
import { OverridesOf } from '../../tests/helpers/types.js'
import { getProjectFake } from '../../tests/fakes.js'
import { getProjectFake, getWorkspaceFake } from '../../tests/fakes.js'
import { TIME_MS } from '../../core/index.js'
import { ProjectVisibility } from '../domain/projects/types.js'
@@ -52,7 +52,7 @@ describe('ensureMinimumProjectRoleFragment', () => {
workspaceId: 'workspaceId',
visibility: ProjectVisibility.Workspace
}),
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspaceId',
slug: 'workspaceSlug'
}),
@@ -290,7 +290,7 @@ describe('ensureProjectWorkspaceAccessFragment', () => {
id: 'projectId',
workspaceId: 'workspaceId'
}),
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspaceId',
slug: 'workspaceSlug'
}),
@@ -450,7 +450,7 @@ describe('ensureImplicitProjectMemberWithReadAccessFragment', async () => {
visibility: ProjectVisibility.Workspace
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspaceId',
slug: 'workspaceSlug'
}),
@@ -688,7 +688,7 @@ describe('ensureImplicitProjectMemberWithWriteAccessFragment', () => {
visibility: ProjectVisibility.Workspace
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspaceId',
slug: 'workspaceSlug'
}),
@@ -1,16 +1,23 @@
import { describe, expect, it } from 'vitest'
import {
ensureWorkspaceRoleAndSessionFragment,
ensureWorkspacesEnabledFragment
ensureWorkspacesEnabledFragment,
ensureUserIsWorkspaceAdminFragment
} from './workspaces.js'
import cryptoRandomString from 'crypto-random-string'
import {
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceNotEnoughPermissionsError,
WorkspacesNotEnabledError,
WorkspaceSsoSessionNoAccessError
} from '../domain/authErrors.js'
import { OverridesOf } from '../../tests/helpers/types.js'
import { parseFeatureFlags } from '../../environment/index.js'
import { Roles } from '../../core/constants.js'
import { getWorkspaceFake } from '../../tests/fakes.js'
describe('ensureWorkspaceRoleAndSessionFragment', () => {
it('hides non existing workspaces behind a WorkspaceNoAccessError', async () => {
@@ -35,7 +42,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
})
it('returns WorkspaceNoAccessError if the user does not have a workspace role', async () => {
const result = await ensureWorkspaceRoleAndSessionFragment({
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'aaa',
slug: 'bbb'
}),
@@ -56,7 +63,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
})
it('returns ok w/o checking session if user is a workspace guest', async () => {
const result = await ensureWorkspaceRoleAndSessionFragment({
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'aaa',
slug: 'bbb'
}),
@@ -75,7 +82,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
})
it('returns just(ok()) if user is a member and workspace has no SSO provider', async () => {
const result = await ensureWorkspaceRoleAndSessionFragment({
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'aaa',
slug: 'bbb'
}),
@@ -92,7 +99,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
})
it('returns WorkspaceSsoSessionInvalidError if user does not have an SSO session', async () => {
const result = ensureWorkspaceRoleAndSessionFragment({
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'aaa',
slug: 'bbb'
}),
@@ -120,7 +127,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
validUntil.setDate(validUntil.getDate() - 1)
const result = await ensureWorkspaceRoleAndSessionFragment({
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'aaa',
slug: 'bbb'
}),
@@ -148,7 +155,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
validUntil.setDate(validUntil.getDate() + 100)
const result = await ensureWorkspaceRoleAndSessionFragment({
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'aaa',
slug: 'bbb'
}),
@@ -194,3 +201,123 @@ describe('ensureWorkspacesEnabledFragment', () => {
})
})
})
describe('ensureUserIsWorkspaceAdminFragment', () => {
const buildEnsureUserIsWorkspaAdminFragment = (
overrides?: Partial<Parameters<typeof ensureUserIsWorkspaceAdminFragment>[0]>
) => {
const workspaceId = cryptoRandomString({ length: 9 })
return ensureUserIsWorkspaceAdminFragment({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getServerRole: async () => Roles.Server.Admin,
getWorkspace: getWorkspaceFake({
id: workspaceId,
slug: cryptoRandomString({ length: 9 })
}),
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getWorkspacePlan: async () => {
return {
workspaceId,
name: 'unlimited' as const,
status: 'valid' as const,
createdAt: new Date(),
updatedAt: new Date()
}
},
...overrides
})
}
const getPolicyArgs = () => ({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 })
})
it('returns error if workspaces is not enabled', async () => {
const policy = buildEnsureUserIsWorkspaAdminFragment({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' })
})
const result = await policy({
...getPolicyArgs(),
userId: undefined
})
expect(result).toBeAuthErrorResult({
code: WorkspacesNotEnabledError.code
})
})
it('returns error if user is not logged in', async () => {
const policy = buildEnsureUserIsWorkspaAdminFragment()
const result = await policy({
...getPolicyArgs(),
userId: undefined
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('returns error if user is not found', async () => {
const policy = buildEnsureUserIsWorkspaAdminFragment({
getServerRole: async () => null
})
const result = await policy(getPolicyArgs())
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('returns error if user is a server guest', async () => {
const policy = buildEnsureUserIsWorkspaAdminFragment({
getServerRole: async () => Roles.Server.Guest
})
const result = await policy(getPolicyArgs())
expect(result).toBeAuthErrorResult({
code: ServerNotEnoughPermissionsError.code
})
})
it('returns error if workspace does not exist', async () => {
const policy = buildEnsureUserIsWorkspaAdminFragment({
getWorkspace: async () => null
})
const result = await policy(getPolicyArgs())
expect(result).toBeAuthErrorResult({
code: WorkspaceNoAccessError.code
})
})
it('returns error if user is not workspace admin', async () => {
const policy = buildEnsureUserIsWorkspaAdminFragment({
getWorkspaceRole: async () => Roles.Workspace.Member
})
const result = await policy(getPolicyArgs())
expect(result).toBeAuthErrorResult({
code: WorkspaceNotEnoughPermissionsError.code
})
})
it('returns ok if user is workspace admin', async () => {
const policy = buildEnsureUserIsWorkspaAdminFragment()
const result = await policy(getPolicyArgs())
expect(result).toBeAuthOKResult()
})
})
@@ -7,6 +7,9 @@ import {
import {
PersonalProjectsLimitedError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceLimitsReachedError,
WorkspaceNoAccessError,
WorkspaceNoEditorSeatError,
@@ -25,6 +28,7 @@ import {
} from '../domain/context.js'
import { isWorkspacePlanStatusReadOnly } from '../../workspaces/helpers/plans.js'
import { hasEditorSeat } from '../checks/workspaceSeat.js'
import { ensureMinimumServerRoleFragment } from './server.js'
/**
* Ensure user has a workspace role, and a valid SSO session (if SSO is configured)
@@ -303,3 +307,45 @@ export const ensureModelCanBeCreatedFragment: AuthPolicyEnsureFragment<
return ok()
}
}
export const ensureUserIsWorkspaceAdminFragment: AuthPolicyEnsureFragment<
| typeof Loaders.getEnv
| typeof Loaders.getServerRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getWorkspacePlan,
WorkspaceContext & MaybeUserContext,
InstanceType<
| typeof WorkspaceNoAccessError
| typeof WorkspaceSsoSessionNoAccessError
| typeof WorkspacesNotEnabledError
| typeof ServerNoSessionError
| typeof ServerNoAccessError
| typeof ServerNotEnoughPermissionsError
| typeof WorkspaceNotEnoughPermissionsError
>
> =
(loaders) =>
async ({ userId, workspaceId }) => {
const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({})
if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error)
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: Roles.Server.User
})
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
const ensuredWorkspaceAccess = await ensureWorkspaceRoleAndSessionFragment(loaders)(
{
userId: userId!,
workspaceId,
role: Roles.Workspace.Admin
}
)
if (ensuredWorkspaceAccess.isErr) return err(ensuredWorkspaceAccess.error)
return ok()
}
+3 -1
View File
@@ -30,6 +30,7 @@ import { canPublishPolicy } from './project/canPublish.js'
import { canLoadPolicy } from './project/canLoad.js'
import { canUpdateEmbedOptionsPolicy } from './workspace/canUpdateEmbedOptions.js'
import { canReadMemberEmailPolicy } from './workspace/canReadMemberEmail.js'
import { canCreateWorkspacePolicy } from './workspace/canCreateWorkspace.js'
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
project: {
@@ -75,7 +76,8 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
canReceiveProjectsUpdatedMessage:
canReceiveWorkspaceProjectsUpdatedMessagePolicy(loaders),
canUpdateEmbedOptions: canUpdateEmbedOptionsPolicy(loaders),
canReadMemberEmail: canReadMemberEmailPolicy(loaders)
canReadMemberEmail: canReadMemberEmailPolicy(loaders),
canCreateWorkspace: canCreateWorkspacePolicy(loaders)
}
})
@@ -14,6 +14,7 @@ import {
} from '../../../domain/authErrors.js'
import { TIME_MS } from '../../../../core/index.js'
import { ProjectVisibility } from '../../../domain/projects/types.js'
import { getWorkspaceFake } from '../../../../tests/fakes.js'
const buildCanCreatePolicy = (
overrides?: Partial<Parameters<typeof canCreateAutomationPolicy>[0]>
@@ -134,7 +135,7 @@ describe('canCreateAutomation', () => {
visibility: ProjectVisibility.Private,
allowPublicComments: false
}),
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -14,6 +14,7 @@ import {
} from '../../../domain/authErrors.js'
import { TIME_MS } from '../../../../core/index.js'
import { ProjectVisibility } from '../../../domain/projects/types.js'
import { getWorkspaceFake } from '../../../../tests/fakes.js'
const buildCanDeletePolicy = (
overrides?: Partial<Parameters<typeof canDeleteAutomationPolicy>[0]>
@@ -134,7 +135,7 @@ describe('canDeleteAutomation', () => {
visibility: ProjectVisibility.Private,
allowPublicComments: false
}),
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -14,6 +14,7 @@ import {
} from '../../../domain/authErrors.js'
import { TIME_MS } from '../../../../core/index.js'
import { ProjectVisibility } from '../../../domain/projects/types.js'
import { getWorkspaceFake } from '../../../../tests/fakes.js'
const buildCanUpdatePolicy = (
overrides?: Partial<Parameters<typeof canUpdateAutomationPolicy>[0]>
@@ -134,7 +135,7 @@ describe('canUpdateAutomation', () => {
visibility: ProjectVisibility.Private,
allowPublicComments: false
}),
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../tests/helpers/types.js'
import { canBroadcastProjectActivityPolicy } from './canBroadcastActivity.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
import { Roles } from '../../../core/constants.js'
import {
ProjectNoAccessError,
@@ -48,7 +48,7 @@ describe('canBroadcastProjectActivityPolicy', () => {
visibility: ProjectVisibility.Workspace
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { canDeleteProjectPolicy } from './canDelete.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
import { Roles } from '../../../core/constants.js'
import { TIME_MS } from '../../../core/index.js'
@@ -32,7 +32,7 @@ describe('canDeleteProjectPolicy', () => {
id: 'project-id',
workspaceId: 'workspace-id'
}),
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -11,7 +11,7 @@ import {
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
import { TIME_MS } from '../../../core/helpers/timeConstants.js'
describe('canLeaveProjectPolicy', () => {
@@ -39,7 +39,7 @@ describe('canLeaveProjectPolicy', () => {
workspaceId: 'workspace-id'
}),
getProjectRole: async () => Roles.Stream.Reviewer,
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -13,6 +13,7 @@ import {
} from '../../domain/authErrors.js'
import { TIME_MS } from '../../../core/index.js'
import { ProjectVisibility } from '../../domain/projects/types.js'
import { getWorkspaceFake } from '../../../tests/fakes.js'
const buildCanLoadPolicy = (overrides?: Partial<Parameters<typeof canLoadPolicy>[0]>) =>
canLoadPolicy({
@@ -164,7 +165,7 @@ describe('canLoad', () => {
visibility: ProjectVisibility.Workspace,
allowPublicComments: false
}),
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -13,6 +13,7 @@ import {
} from '../../domain/authErrors.js'
import { TIME_MS } from '../../../core/index.js'
import { ProjectVisibility } from '../../domain/projects/types.js'
import { getWorkspaceFake } from '../../../tests/fakes.js'
const buildCanPublishPolicy = (
overrides?: Partial<Parameters<typeof canPublishPolicy>[0]>
@@ -122,7 +123,7 @@ describe('canPublish', () => {
visibility: ProjectVisibility.Workspace,
allowPublicComments: false
}),
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -11,9 +11,8 @@ import {
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
import cryptoRandomString from 'crypto-random-string'
import { AuthCheckContextLoaders } from '../../domain/loaders.js'
import { ProjectVisibility } from '../../domain/projects/types.js'
const canReadProjectArgs = () => {
@@ -22,7 +21,7 @@ const canReadProjectArgs = () => {
return { projectId, userId }
}
const getWorkspace: AuthCheckContextLoaders['getWorkspace'] = async () => ({
const getWorkspace = getWorkspaceFake({
id: 'aaa',
slug: 'bbb'
})
@@ -10,7 +10,7 @@ import {
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
import { TIME_MS } from '../../../core/helpers/timeConstants.js'
import { ProjectVisibility } from '../../domain/projects/types.js'
@@ -45,7 +45,7 @@ describe('canReadProjectSettingsPolicy', () => {
visibility: ProjectVisibility.Workspace
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -11,7 +11,7 @@ import {
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { canReadProjectWebhooksPolicy } from './canReadWebhooks.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
import { TIME_MS } from '../../../core/helpers/timeConstants.js'
import { ProjectVisibility } from '../../domain/projects/types.js'
@@ -43,7 +43,7 @@ describe('canReadProjectWebhooksPolicy', () => {
visibility: ProjectVisibility.Workspace
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -12,7 +12,7 @@ import {
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
import { TIME_MS } from '../../../core/index.js'
// Default deps allow test to succeed, this makes it so that we need to override less of them
@@ -40,10 +40,7 @@ const buildWorkspaceSUT = (
id: 'project-id',
workspaceId: 'workspace-id'
}),
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'
import { canArchiveProjectCommentPolicy } from './canArchive.js'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getCommentFake, getProjectFake } from '../../../../tests/fakes.js'
import {
getCommentFake,
getProjectFake,
getWorkspaceFake
} from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
CommentNotFoundError,
@@ -48,10 +52,7 @@ describe('canArchiveProjectCommentPolicy', () => {
visibility: ProjectVisibility.Workspace
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { canCreateProjectCommentPolicy } from './canCreate.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getProjectFake } from '../../../../tests/fakes.js'
import { getProjectFake, getWorkspaceFake } from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
ProjectNoAccessError,
@@ -42,7 +42,7 @@ describe('canCreateProjectCommentPolicy', () => {
visibility: ProjectVisibility.Workspace
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getCommentFake, getProjectFake } from '../../../../tests/fakes.js'
import {
getCommentFake,
getProjectFake,
getWorkspaceFake
} from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
CommentNoAccessError,
@@ -49,10 +53,7 @@ describe('canEditProjectCommentPolicy', () => {
visibility: ProjectVisibility.Workspace
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Roles } from '../../../../core/constants.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getModelFake, getProjectFake } from '../../../../tests/fakes.js'
import {
getModelFake,
getProjectFake,
getWorkspaceFake
} from '../../../../tests/fakes.js'
import {
ModelNotFoundError,
ProjectNoAccessError,
@@ -45,7 +49,7 @@ const buildWorkspaceSUT = (
id: 'project-id',
workspaceId: 'workspace-id'
}),
getWorkspace: async () => ({
getWorkspace: getWorkspaceFake({
id: 'workspace-id',
slug: 'workspace-slug'
}),
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Roles } from '../../../../core/constants.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getProjectFake } from '../../../../tests/fakes.js'
import { getProjectFake, getWorkspaceFake } from '../../../../tests/fakes.js'
import { canUpdateModelPolicy } from './canUpdate.js'
import {
ProjectNoAccessError,
@@ -37,10 +37,7 @@ const buildWorkspaceSUT = (
id: 'project-id',
workspaceId: 'workspace-id'
}),
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Roles } from '../../../../core/constants.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getProjectFake, getVersionFake } from '../../../../tests/fakes.js'
import {
getProjectFake,
getVersionFake,
getWorkspaceFake
} from '../../../../tests/fakes.js'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { canUpdateProjectVersionPolicy } from './canUpdate.js'
import {
@@ -46,10 +50,7 @@ const buildWorkspaceSUT = (
id: 'project-id',
workspaceId: 'workspace-id'
}),
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
@@ -0,0 +1,380 @@
import { assert, describe, expect, it } from 'vitest'
import {
EligibleForExclusiveWorkspaceError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspacesNotEnabledError
} from '../../domain/authErrors.js'
import { canCreateWorkspacePolicy } from './canCreateWorkspace.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import cryptoRandomString from 'crypto-random-string'
import { Roles } from '../../../core/constants.js'
const createTestArgs = () => ({
userId: cryptoRandomString({ length: 10 })
})
describe('canCreateWorkspacePolicy creates a function, that handles', () => {
describe('server environment configuration by', () => {
it('forbids creation if the workspaces module is not enabled', async () => {
const result = await canCreateWorkspacePolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'false'
}),
getServerRole: async () => {
assert.fail()
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async () => {
assert.fail()
}
})(createTestArgs())
expect(result).toBeAuthErrorResult({
code: WorkspacesNotEnabledError.code
})
})
})
describe('user server roles', () => {
it('forbids creation for users without a session', async () => {
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return null
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async () => {
assert.fail()
}
})({})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('forbids creation for users with no server role', async () => {
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return null
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async () => {
assert.fail()
}
})({ userId: cryptoRandomString({ length: 10 }) })
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('forbids creation for users with insufficient server role', async () => {
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return Roles.Server.Guest
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async () => {
assert.fail()
}
})(createTestArgs())
expect(result).toBeAuthErrorResult({
code: ServerNotEnoughPermissionsError.code
})
})
})
describe('workspace eligibility', () => {
it('forbids creation for users eligible for exclusive workspaces', async () => {
const userId = cryptoRandomString({ length: 10 })
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return Roles.Server.User
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
userId: queriedUserId
}) => {
expect(queriedUserId).toBe(userId)
return [
{
id: cryptoRandomString({ length: 10 }),
name: 'Regular Workspace',
slug: 'regular-workspace',
isExclusive: false
},
{
id: cryptoRandomString({ length: 10 }),
name: 'Exclusive Workspace',
slug: 'exclusive-workspace',
isExclusive: true
}
]
}
})({ userId })
expect(result).toBeAuthErrorResult({
code: EligibleForExclusiveWorkspaceError.code
})
})
it('allows creation for users not eligible for any exclusive workspaces', async () => {
const userId = cryptoRandomString({ length: 10 })
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return Roles.Server.User
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
userId: queriedUserId
}) => {
expect(queriedUserId).toBe(userId)
return [
{
id: cryptoRandomString({ length: 10 }),
name: 'Regular Workspace 1',
slug: 'regular-workspace-1',
isExclusive: false
},
{
id: cryptoRandomString({ length: 10 }),
name: 'Regular Workspace 2',
slug: 'regular-workspace-2',
isExclusive: false
}
]
}
})({ userId })
expect(result).toBeAuthOKResult()
})
it('allows creation for users with no eligible workspaces', async () => {
const userId = cryptoRandomString({ length: 10 })
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return Roles.Server.User
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
userId: queriedUserId
}) => {
expect(queriedUserId).toBe(userId)
return []
}
})({ userId })
expect(result).toBeAuthOKResult()
})
it('allows creation for admin users even if eligible for exclusive workspaces', async () => {
const userId = cryptoRandomString({ length: 10 })
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return Roles.Server.Admin
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
userId: queriedUserId
}) => {
expect(queriedUserId).toBe(userId)
return [
{
id: cryptoRandomString({ length: 10 }),
name: 'Exclusive Workspace',
slug: 'exclusive-workspace',
isExclusive: true,
role: Roles.Workspace.Admin
}
]
}
})({ userId })
expect(result).toBeAuthOKResult()
})
it('allows creation for workspace admins of exclusive workspaces', async () => {
const userId = cryptoRandomString({ length: 10 })
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return Roles.Server.User
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
userId: queriedUserId
}) => {
expect(queriedUserId).toBe(userId)
return [
{
id: cryptoRandomString({ length: 10 }),
name: 'Exclusive Workspace',
slug: 'exclusive-workspace',
isExclusive: true,
role: Roles.Workspace.Admin
}
]
}
})({ userId })
expect(result).toBeAuthOKResult()
})
it('allows creation for workspace guests of exclusive workspaces', async () => {
const userId = cryptoRandomString({ length: 10 })
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return Roles.Server.User
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
userId: queriedUserId
}) => {
expect(queriedUserId).toBe(userId)
return [
{
id: cryptoRandomString({ length: 10 }),
name: 'Exclusive Workspace',
slug: 'exclusive-workspace',
isExclusive: true,
role: Roles.Workspace.Guest
}
]
}
})({ userId })
expect(result).toBeAuthOKResult()
})
it('forbids creation for workspace members of exclusive workspaces', async () => {
const userId = cryptoRandomString({ length: 10 })
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return Roles.Server.User
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
userId: queriedUserId
}) => {
expect(queriedUserId).toBe(userId)
return [
{
id: cryptoRandomString({ length: 10 }),
name: 'Exclusive Workspace',
slug: 'exclusive-workspace',
isExclusive: true,
role: Roles.Workspace.Member
}
]
}
})({ userId })
expect(result).toBeAuthErrorResult({
code: EligibleForExclusiveWorkspaceError.code
})
})
it('forbids creation for workspace admins if they have a member role on an exclusive workspace', async () => {
const userId = cryptoRandomString({ length: 10 })
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return Roles.Server.User
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
userId: queriedUserId
}) => {
expect(queriedUserId).toBe(userId)
return [
{
id: cryptoRandomString({ length: 10 }),
name: 'Exclusive Workspace 1',
slug: 'exclusive-workspace-1',
isExclusive: true,
role: Roles.Workspace.Admin
},
{
id: cryptoRandomString({ length: 10 }),
name: 'Exclusive Workspace 2',
slug: 'exclusive-workspace-2',
isExclusive: true,
role: Roles.Workspace.Member
}
]
}
})({ userId })
expect(result).toBeAuthErrorResult({
code: EligibleForExclusiveWorkspaceError.code
})
})
it('allows creation for workspace admins even with mixed exclusive workspace roles', async () => {
const userId = cryptoRandomString({ length: 10 })
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return Roles.Server.User
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
userId: queriedUserId
}) => {
expect(queriedUserId).toBe(userId)
return [
{
id: cryptoRandomString({ length: 10 }),
name: 'Exclusive Workspace 1',
slug: 'exclusive-workspace-1',
isExclusive: true,
role: Roles.Workspace.Admin
},
{
id: cryptoRandomString({ length: 10 }),
name: 'Exclusive Workspace 2',
slug: 'exclusive-workspace-2',
isExclusive: true,
role: Roles.Workspace.Guest
}
]
}
})({ userId })
expect(result).toBeAuthOKResult()
})
it('allows creation when all workspace roles pass the policy', async () => {
const userId = cryptoRandomString({ length: 10 })
const result = await canCreateWorkspacePolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getServerRole: async () => {
return Roles.Server.User
},
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
userId: queriedUserId
}) => {
expect(queriedUserId).toBe(userId)
return [
{
id: cryptoRandomString({ length: 10 }),
name: 'Exclusive Workspace 1',
slug: 'exclusive-workspace-1',
isExclusive: false,
role: Roles.Workspace.Member
},
{
id: cryptoRandomString({ length: 10 }),
name: 'Exclusive Workspace 2',
slug: 'exclusive-workspace-2',
isExclusive: true,
role: Roles.Workspace.Guest
}
]
}
})({ userId })
expect(result).toBeAuthOKResult()
})
})
})
@@ -0,0 +1,77 @@
import { err, ok } from 'true-myth/result'
import {
EligibleForExclusiveWorkspaceError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspacesNotEnabledError
} from '../../domain/authErrors.js'
import { MaybeUserContext } from '../../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { AuthPolicy } from '../../domain/policies.js'
import { ensureWorkspacesEnabledFragment } from '../../fragments/workspaces.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import { Roles } from '../../../core/constants.js'
import { throwUncoveredError } from '../../../core/index.js'
type PolicyArgs = MaybeUserContext
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getUsersCurrentAndEligibleToBecomeAMemberWorkspaces
| typeof AuthCheckContextLoaderKeys.getServerRole
type PolicyErrors = InstanceType<
| typeof WorkspacesNotEnabledError
| typeof ServerNoSessionError
| typeof ServerNoAccessError
| typeof ServerNotEnoughPermissionsError
| typeof EligibleForExclusiveWorkspaceError
>
export const canCreateWorkspacePolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId }) => {
const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({})
if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error)
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: Roles.Server.User
})
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
// userId is not null here, ensured by the serverRoleFragment
const workspaces =
await loaders.getUsersCurrentAndEligibleToBecomeAMemberWorkspaces({
userId: userId!
})
const isUserEligibleForExclusiveWorkspaces = workspaces.some((w) => {
if (w.isExclusive) {
// if the user has no role in the workspace, means they are eligible
// to join it via an invite or discovery
if (!w.role) return true
// for exclusive workspaces, if the user has a role, some of them are not affected by this policy
// ie.: Workspace admins of exclusive workspaces should be able to create new ones
// also guests should not be bound by this rule
switch (w.role) {
case Roles.Workspace.Admin:
case Roles.Workspace.Guest:
return false
case Roles.Workspace.Member:
return true
default:
throwUncoveredError(w.role)
}
}
return false
})
if (isUserEligibleForExclusiveWorkspaces) {
return err(new EligibleForExclusiveWorkspaceError())
}
return ok()
}
@@ -1,8 +1,6 @@
import cryptoRandomString from 'crypto-random-string'
import { Roles } from '../../../core/constants.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { Workspace } from '../../domain/workspaces/types.js'
import { WorkspacePlan } from '../../../workspaces/index.js'
import cryptoRandomString from 'crypto-random-string'
import { describe, expect, it } from 'vitest'
import {
ServerNoAccessError,
@@ -13,45 +11,44 @@ import {
WorkspacesNotEnabledError
} from '../../domain/authErrors.js'
import { canReadMemberEmailPolicy } from './canReadMemberEmail.js'
const buildCanReadMemberEmailPolicy = (
overrides?: Partial<Parameters<typeof canReadMemberEmailPolicy>[0]>
) => {
const workspaceId = cryptoRandomString({ length: 9 })
return canReadMemberEmailPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getServerRole: async () => Roles.Server.Admin,
getWorkspace: async () => {
return {
id: workspaceId,
slug: cryptoRandomString({ length: 9 })
} as Workspace
},
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getWorkspacePlan: async () => {
return {
workspaceId,
name: 'unlimited',
status: 'valid',
createdAt: new Date()
} as WorkspacePlan
},
...overrides
})
}
const getPolicyArgs = () => ({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 })
})
import { getWorkspaceFake } from '../../../tests/fakes.js'
describe('canReadMemberEmailPolicy', () => {
const workspaceId = cryptoRandomString({ length: 9 })
const buildCanReadMemberEmailPolicy = (
overrides?: Partial<Parameters<typeof canReadMemberEmailPolicy>[0]>
) => {
return canReadMemberEmailPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getServerRole: async () => Roles.Server.Admin,
getWorkspace: getWorkspaceFake({
id: workspaceId,
slug: cryptoRandomString({ length: 9 })
}),
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getWorkspacePlan: async () => {
return {
workspaceId,
name: 'unlimited' as const,
status: 'valid' as const,
createdAt: new Date(),
updatedAt: new Date()
}
},
...overrides
})
}
const getPolicyArgs = () => ({
userId: cryptoRandomString({ length: 9 }),
workspaceId
})
it('returns error if workspaces is not enabled', async () => {
const policy = buildCanReadMemberEmailPolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' })
@@ -1,11 +1,7 @@
import { err, ok } from 'true-myth/result'
import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureWorkspaceRoleAndSessionFragment,
ensureWorkspacesEnabledFragment
} from '../../fragments/workspaces.js'
import { ensureUserIsWorkspaceAdminFragment } from '../../fragments/workspaces.js'
import {
ServerNoAccessError,
ServerNoSessionError,
@@ -15,8 +11,6 @@ import {
WorkspacesNotEnabledError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import { Roles } from '../../../core/constants.js'
type PolicyArgs = MaybeUserContext & WorkspaceContext
@@ -29,14 +23,15 @@ type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
type PolicyErrors =
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
| InstanceType<typeof WorkspacesNotEnabledError>
| InstanceType<typeof ServerNoSessionError>
| InstanceType<typeof ServerNoAccessError>
| InstanceType<typeof ServerNotEnoughPermissionsError>
| InstanceType<typeof WorkspaceNotEnoughPermissionsError>
type PolicyErrors = InstanceType<
| typeof WorkspaceNoAccessError
| typeof WorkspaceSsoSessionNoAccessError
| typeof WorkspacesNotEnabledError
| typeof ServerNoSessionError
| typeof ServerNoAccessError
| typeof ServerNotEnoughPermissionsError
| typeof WorkspaceNotEnoughPermissionsError
>
export const canReadMemberEmailPolicy: AuthPolicy<
PolicyLoaderKeys,
@@ -45,23 +40,5 @@ export const canReadMemberEmailPolicy: AuthPolicy<
> =
(loaders) =>
async ({ userId, workspaceId }) => {
const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({})
if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error)
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: Roles.Server.User
})
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
const ensuredWorkspaceAccess = await ensureWorkspaceRoleAndSessionFragment(loaders)(
{
userId: userId!,
workspaceId,
role: Roles.Workspace.Admin
}
)
if (ensuredWorkspaceAccess.isErr) return err(ensuredWorkspaceAccess.error)
return ok()
return ensureUserIsWorkspaceAdminFragment(loaders)({ userId, workspaceId })
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { canReceiveWorkspaceProjectsUpdatedMessagePolicy } from './canReceiveProjectsUpdatedMessage.js'
import { OverridesOf } from '../../../tests/helpers/types.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
import { Roles } from '../../../core/constants.js'
import { TIME_MS } from '../../../core/index.js'
import { parseFeatureFlags } from '../../../environment/index.js'
@@ -25,10 +25,7 @@ describe('canReceiveWorkspaceProjectsUpdatedMessagePolicy', () => {
}),
getProjectRole: async () => Roles.Stream.Reviewer,
getServerRole: async () => Roles.Server.Guest,
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
getWorkspaceRole: async () => Roles.Workspace.Guest,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
@@ -20,7 +20,10 @@ import {
} from '../../fragments/workspaces.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import { Roles } from '../../../core/constants.js'
import { WorkspacePlans } from '../../../workspaces/index.js'
import {
WorkspacePlanFeatures,
workspacePlanHasAccessToFeature
} from '../../../workspaces/index.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
@@ -74,16 +77,11 @@ export const canUpdateEmbedOptionsPolicy: AuthPolicy<
})
if (ensuredNotReadOnly.isErr) return err(ensuredNotReadOnly.error)
const validPlans: WorkspacePlans[] = [
'academia',
'unlimited',
'pro',
'proUnlimited',
'proUnlimitedInvoiced'
]
const workspacePlan = await loaders.getWorkspacePlan({ workspaceId })
if (!workspacePlan || !validPlans.includes(workspacePlan.name))
return err(new WorkspaceNoFeatureAccessError())
return ok()
if (!workspacePlan) return err(new WorkspaceNoFeatureAccessError())
const canUpdateEmbedOptions = workspacePlanHasAccessToFeature({
plan: workspacePlan.name,
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
return canUpdateEmbedOptions ? ok() : err(new WorkspaceNoFeatureAccessError())
}
+2 -1
View File
@@ -28,7 +28,8 @@ export const getProjectFake = fakeGetFactory<Project>(() => ({
export const getWorkspaceFake = fakeGetFactory<Workspace>(() => ({
id: nanoid(10),
slug: nanoid(10)
slug: nanoid(10),
isExclusive: false
}))
export const getCommentFake = fakeGetFactory<Comment>(() => ({
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import {
workspacePlanHasAccessToFeature,
WorkspacePlanFeatures,
WorkspacePlanConfigs
} from './features.js'
import { WorkspacePlans } from './plans.js'
describe('workspacePlanHasAccessToFeature', () => {
describe('Comprehensive feature coverage', () => {
const allPlans = Object.values(WorkspacePlans) as WorkspacePlans[]
const allFeatures = Object.values(WorkspacePlanFeatures) as WorkspacePlanFeatures[]
describe.each(allPlans)('should work for %s plan', (plan) => {
it.each(allFeatures)('%s feature combination', (feature) => {
const expectedResult = WorkspacePlanConfigs[plan].features.includes(feature)
const actualResult = workspacePlanHasAccessToFeature({ plan, feature })
expect(
actualResult,
`Plan ${plan} feature ${feature} access should be ${expectedResult}`
).toBe(expectedResult)
})
})
})
})
@@ -244,3 +244,15 @@ export const workspaceReachedPlanLimit = (
return projectCount === limits.projectCount || modelCount === limits.modelCount
}
export const workspacePlanHasAccessToFeature = ({
plan,
feature
}: {
plan: WorkspacePlans
feature: WorkspacePlanFeatures
}): boolean => {
const planConfig = WorkspacePlanConfigs[plan]
const hasAccess = planConfig.features.includes(feature)
return hasAccess
}