diff --git a/packages/frontend-2/lib/common/helpers/graphql.ts b/packages/frontend-2/lib/common/helpers/graphql.ts index 143d0a54b..f86ab23b0 100644 --- a/packages/frontend-2/lib/common/helpers/graphql.ts +++ b/packages/frontend-2/lib/common/helpers/graphql.ts @@ -501,7 +501,7 @@ export const resolveGenericStatusCode = ( if (errors.some((e) => e.extensions?.code === 'FORBIDDEN')) return 403 if ( errors.some((e) => - ['UNAUTHENTICATED', 'UNAUTHORIZED_ACCESS_ERROR'].includes( + ['UNAUTHENTICATED', 'UNAUTHORIZED', 'UNAUTHORIZED_ACCESS_ERROR'].includes( (e.extensions?.code || '') as string ) ) diff --git a/packages/frontend-2/lib/core/configs/apollo.ts b/packages/frontend-2/lib/core/configs/apollo.ts index 218993da8..4cb9807aa 100644 --- a/packages/frontend-2/lib/core/configs/apollo.ts +++ b/packages/frontend-2/lib/core/configs/apollo.ts @@ -421,7 +421,11 @@ function createWsClient(params: { const coreShouldSkipLoggingErrors = (err: ErrorResponse): boolean => { // These fields have special auth requirements and will often throw errors that we don't want to log const specialAuthFields = ['invitedTeam', 'billing', 'domains', 'subscription'] - const specialAuthFieldErrorCodes = ['FORBIDDEN', 'UNAUTHORIZED_ACCESS_ERROR'] + const specialAuthFieldErrorCodes = [ + 'FORBIDDEN', + 'UNAUTHORIZED', + 'UNAUTHORIZED_ACCESS_ERROR' + ] return !!( err.graphQLErrors && diff --git a/packages/server/assets/accessrequests/typedefs/accessrequests.graphql b/packages/server/assets/accessrequests/typedefs/accessrequests.graphql index 4353cc3de..502a8d54c 100644 --- a/packages/server/assets/accessrequests/typedefs/accessrequests.graphql +++ b/packages/server/assets/accessrequests/typedefs/accessrequests.graphql @@ -4,6 +4,7 @@ extend type Query { """ streamAccessRequest(streamId: String!): StreamAccessRequest @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "profile:read") @deprecated( reason: "Part of the old API surface and will be removed in the future. Use User.projectAccessRequest instead." ) diff --git a/packages/server/assets/auth/typedefs/apps.graphql b/packages/server/assets/auth/typedefs/apps.graphql index 840523976..747a94bb8 100644 --- a/packages/server/assets/auth/typedefs/apps.graphql +++ b/packages/server/assets/auth/typedefs/apps.graphql @@ -8,6 +8,7 @@ extend type Query { Returns all the publicly available apps on this server. """ apps: [ServerAppListItem] + @hasScope(scope: "apps:read") @deprecated( reason: "Part of the old API surface and will be removed in the future." ) diff --git a/packages/server/assets/automate/typedefs/automate.graphql b/packages/server/assets/automate/typedefs/automate.graphql index f15dcff42..e4470ec15 100644 --- a/packages/server/assets/automate/typedefs/automate.graphql +++ b/packages/server/assets/automate/typedefs/automate.graphql @@ -330,16 +330,11 @@ extend type ProjectMutations { type AutomateMutations { createFunction(input: CreateAutomateFunctionInput!): AutomateFunction! - @hasScope(scope: "automate-functions:write") createFunctionWithoutVersion( input: CreateAutomateFunctionWithoutVersionInput! - ): AutomateFunctionToken! - @hasScope(scope: "automate-functions:write") - @hasServerRole(role: SERVER_ADMIN) + ): AutomateFunctionToken! @hasServerRole(role: SERVER_ADMIN) updateFunction(input: UpdateAutomateFunctionInput!): AutomateFunction! - @hasScope(scope: "automate-functions:write") regenerateFunctionToken(functionId: String!): String! - @hasScope(scope: "automate-functions:write") } extend type Project { @@ -369,6 +364,7 @@ extend type Query { Get a single automate function by id. Error will be thrown if function is not found or inaccessible. """ automateFunction(id: ID!): AutomateFunction! + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "automate-functions:read") """ Part of the automation/function creation handshake mechanism @@ -385,7 +381,9 @@ extend type Mutation { ): Boolean! @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "automate:report-results") - automateMutations: AutomateMutations! @hasServerRole(role: SERVER_GUEST) + automateMutations: AutomateMutations! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "automate-functions:write") } enum ProjectTriggeredAutomationsStatusUpdatedMessageType { diff --git a/packages/server/assets/comments/typedefs/comments.gql b/packages/server/assets/comments/typedefs/comments.gql index dbfe6c7e8..9dcf799d1 100644 --- a/packages/server/assets/comments/typedefs/comments.gql +++ b/packages/server/assets/comments/typedefs/comments.gql @@ -1,5 +1,6 @@ extend type Query { comment(id: String!, streamId: String!): Comment + @hasScope(scope: "streams:read") @deprecated( reason: "Part of the old API surface and will be removed in the future. Use Project.comment instead." ) @@ -16,6 +17,7 @@ extend type Query { cursor: String archived: Boolean! = false ): CommentCollection + @hasScope(scope: "streams:read") @deprecated(reason: "Use Project/Version/Model 'commentThreads' fields instead") } @@ -356,7 +358,9 @@ type CommentMutations { } extend type Mutation { - commentMutations: CommentMutations! @hasServerRole(role: SERVER_GUEST) + commentMutations: CommentMutations! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "streams:write") """ Used for broadcasting real time chat head bubbles and status. Does not persist any info. @@ -367,6 +371,7 @@ extend type Mutation { data: JSONObject ): Boolean! @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "streams:write") @deprecated(reason: "Use broadcastViewerUserActivity") """ @@ -378,6 +383,7 @@ extend type Mutation { data: JSONObject ): Boolean! @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "streams:write") @deprecated(reason: "Use broadcastViewerUserActivity") """ diff --git a/packages/server/assets/comments/typedefs/viewer.gql b/packages/server/assets/comments/typedefs/viewer.gql index 41f6b0e18..7f49850bf 100644 --- a/packages/server/assets/comments/typedefs/viewer.gql +++ b/packages/server/assets/comments/typedefs/viewer.gql @@ -46,7 +46,7 @@ extend type Mutation { projectId: String! resourceIdString: String! message: ViewerUserActivityMessageInput! - ): Boolean! @hasServerRole(role: SERVER_GUEST) + ): Boolean! @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") } extend type Subscription { diff --git a/packages/server/assets/core/typedefs/admin.graphql b/packages/server/assets/core/typedefs/admin.graphql index 36b6f66e1..c45420e58 100644 --- a/packages/server/assets/core/typedefs/admin.graphql +++ b/packages/server/assets/core/typedefs/admin.graphql @@ -46,13 +46,15 @@ type AdminQueries { cursor: String = null query: String = null role: ServerRole = null - ): AdminUserList! @hasScope(scope: "users:read") + ): AdminUserList! @hasServerRole(role: SERVER_ADMIN) @hasScope(scope: "users:read") inviteList( limit: Int! = 25 cursor: String = null query: String = null - ): AdminInviteList! @hasScope(scope: "users:invite") + ): AdminInviteList! + @hasServerRole(role: SERVER_ADMIN) + @hasScope(scope: "users:invite") projectList( query: String @@ -60,9 +62,11 @@ type AdminQueries { visibility: String limit: Int! = 25 cursor: String = null - ): ProjectCollection! + ): ProjectCollection! @hasServerRole(role: SERVER_ADMIN) - serverStatistics: ServerStatistics! @hasScope(scope: "server:stats") + serverStatistics: ServerStatistics! + @hasServerRole(role: SERVER_ADMIN) + @hasScope(scope: "server:stats") } type AdminMutations { @@ -72,10 +76,14 @@ type AdminMutations { with the user before performing this action. """ updateEmailVerification(input: AdminUpdateEmailVerificationInput!): Boolean! + @hasServerRole(role: SERVER_ADMIN) + @hasScope(scope: "users:email") } extend type Query { - admin: AdminQueries! @hasServerRole(role: SERVER_ADMIN) + admin: AdminQueries! + @hasServerRole(role: SERVER_ADMIN) + @hasScope(scope: "server:stats") } extend type Mutation { diff --git a/packages/server/assets/core/typedefs/apitoken.graphql b/packages/server/assets/core/typedefs/apitoken.graphql index 66d4810ff..67af44475 100644 --- a/packages/server/assets/core/typedefs/apitoken.graphql +++ b/packages/server/assets/core/typedefs/apitoken.graphql @@ -2,7 +2,9 @@ extend type Query { """ If user is authenticated using an app token, this will describe the app """ - authenticatedAsApp: ServerAppListItem @hasServerRole(role: SERVER_USER) + authenticatedAsApp: ServerAppListItem + @hasServerRole(role: SERVER_USER) + @hasScope(scope: "apps:read") } extend type User { @@ -111,10 +113,14 @@ extend type Mutation { extend type ProjectMutations { createEmbedToken(token: EmbedTokenCreateInput!): CreateEmbedTokenReturn! + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "tokens:write") revokeEmbedToken(token: String!, projectId: String!): Boolean! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "tokens:write") + revokeEmbedTokens(projectId: String!): Boolean! + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "tokens:write") - revokeEmbedTokens(projectId: String!): Boolean! @hasScope(scope: "tokens:write") } type EmbedTokenCollection { diff --git a/packages/server/assets/core/typedefs/projects.graphql b/packages/server/assets/core/typedefs/projects.graphql index 9272a02c2..4b6dd63b3 100644 --- a/packages/server/assets/core/typedefs/projects.graphql +++ b/packages/server/assets/core/typedefs/projects.graphql @@ -3,7 +3,7 @@ extend type Query { Find a specific project. Will throw an authorization error if active user isn't authorized to see it, for example, if a project isn't public and the user doesn't have the appropriate rights. """ - project(id: String!): Project! + project(id: String!): Project! @hasScope(scope: "streams:read") } enum ProjectVisibility { @@ -188,11 +188,13 @@ type Project { role: String createdAt: DateTime! updatedAt: DateTime! - team: [ProjectCollaborator!]! + team: [ProjectCollaborator!]! @hasScope(scope: "users:read") """ Collaborators who have been invited, but not yet accepted. """ - invitedTeam: [PendingStreamCollaborator!] @hasStreamRole(role: STREAM_CONTRIBUTOR) + invitedTeam: [PendingStreamCollaborator!] + @hasStreamRole(role: STREAM_CONTRIBUTOR) + @hasScope(scope: "users:read") """ Source apps used in any models of this project """ diff --git a/packages/server/assets/core/typedefs/streams.graphql b/packages/server/assets/core/typedefs/streams.graphql index 2379a8796..69f0de373 100644 --- a/packages/server/assets/core/typedefs/streams.graphql +++ b/packages/server/assets/core/typedefs/streams.graphql @@ -4,6 +4,7 @@ extend type Query { to see it, for example, if a stream isn't public and the user doesn't have the appropriate rights. """ stream(id: String!): Stream + @hasScope(scope: "streams:read") @deprecated( reason: "Part of the old API surface and will be removed in the future. Use Query.project instead." ) @@ -30,6 +31,7 @@ extend type Query { limit: Int = 25 ): StreamCollection @hasServerRole(role: SERVER_ADMIN) + @hasScope(scope: "server:stats") @deprecated(reason: "use admin.projectList instead") """ @@ -43,6 +45,7 @@ extend type Query { """ sort: DiscoverableStreamsSortingInput ): StreamCollection + @hasScope(scope: "streams:read") @deprecated( reason: "Part of the old API surface and will be removed in the future." ) @@ -213,6 +216,7 @@ extend type Mutation { streamsDelete(ids: [String!]): Boolean! @hasServerRole(role: SERVER_ADMIN) + @hasScope(scope: "streams:write") @deprecated( reason: "Part of the old API surface and will be removed in the future. Use ProjectMutations.batchDelete instead." ) @@ -240,6 +244,7 @@ extend type Mutation { # Favorite/unfavorite the given stream streamFavorite(streamId: String!, favorited: Boolean!): Stream @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "profile:write") @deprecated( reason: "Part of the old API surface and will be removed in the future." ) @@ -249,6 +254,7 @@ extend type Mutation { """ streamLeave(streamId: String!): Boolean! @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "profile:write") @deprecated( reason: "Part of the old API surface and will be removed in the future. Use ProjectMutations.leave instead." ) diff --git a/packages/server/assets/core/typedefs/user.graphql b/packages/server/assets/core/typedefs/user.graphql index aed5bd23c..6b23111eb 100644 --- a/packages/server/assets/core/typedefs/user.graphql +++ b/packages/server/assets/core/typedefs/user.graphql @@ -2,7 +2,7 @@ extend type Query { """ Gets the profile of the authenticated user or null if not authenticated """ - activeUser: User + activeUser: User @hasScope(scope: "profile:read") """ Get the (limited) profile information of another server user @@ -15,6 +15,7 @@ extend type Query { Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). """ user(id: String): User + @hasScope(scope: "profile:read") @deprecated( reason: "To be removed in the near future! Use 'activeUser' to get info about the active user or 'otherUser' to get info about another user." ) @@ -42,7 +43,9 @@ extend type Query { cursor: String archived: Boolean = false emailOnly: Boolean = false - ): UserSearchResultCollection! @deprecated(reason: "Use users() instead.") + ): UserSearchResultCollection! + @hasScope(scope: "users:read") + @deprecated(reason: "Use users() instead.") """ Look up server users @@ -197,6 +200,7 @@ extend type Mutation { """ userUpdate(user: UserUpdateInput!): Boolean! @deprecated(reason: "Use activeUserMutations version") + @hasScope(scope: "profile:write") """ Delete a user's account. @@ -207,14 +211,18 @@ extend type Mutation { adminDeleteUser(userConfirmation: UserDeleteInput!): Boolean! @hasServerRole(role: SERVER_ADMIN) + @hasScope(scope: "profile:delete") userRoleChange(userRoleInput: UserRoleInput!): Boolean! @hasServerRole(role: SERVER_ADMIN) + @hasScope(scope: "profile:write") """ Various Active User oriented mutations """ - activeUserMutations: ActiveUserMutations! @hasServerRole(role: SERVER_GUEST) + activeUserMutations: ActiveUserMutations! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "profile:write") } input UserRoleInput { diff --git a/packages/server/assets/dashboards/typedefs/dashboards.graphql b/packages/server/assets/dashboards/typedefs/dashboards.graphql index 4c633def7..a1c82760f 100644 --- a/packages/server/assets/dashboards/typedefs/dashboards.graphql +++ b/packages/server/assets/dashboards/typedefs/dashboards.graphql @@ -1,9 +1,11 @@ extend type Query { - dashboard(id: String!): Dashboard! + dashboard(id: String!): Dashboard! @hasScope(scope: "workspace:read") } extend type Mutation { - dashboardMutations: DashboardMutations! @hasServerRole(role: SERVER_GUEST) + dashboardMutations: DashboardMutations! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "workspace:update") } type DashboardMutations { diff --git a/packages/server/assets/emails/typedefs/emails.graphql b/packages/server/assets/emails/typedefs/emails.graphql index f3f835b1f..d774d5bdc 100644 --- a/packages/server/assets/emails/typedefs/emails.graphql +++ b/packages/server/assets/emails/typedefs/emails.graphql @@ -9,6 +9,8 @@ extend type Mutation { """ (Re-)send the account verification e-mail """ - requestVerification: Boolean! @hasServerRole(role: SERVER_GUEST) + requestVerification: Boolean! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "profile:write") requestVerificationByEmail(email: String!): Boolean! } diff --git a/packages/server/assets/fileuploads/typedefs/fileuploads.graphql b/packages/server/assets/fileuploads/typedefs/fileuploads.graphql index a4a30832d..a45c52d54 100644 --- a/packages/server/assets/fileuploads/typedefs/fileuploads.graphql +++ b/packages/server/assets/fileuploads/typedefs/fileuploads.graphql @@ -171,6 +171,7 @@ type FileUploadMutations { After uploading the file, call mutation startFileImport to register the completed upload. """ generateUploadUrl(input: GenerateFileUploadUrlInput!): GenerateFileUploadUrlOutput! + @hasServerRole(role: SERVER_GUEST) """ Before calling this mutation, call generateUploadUrl to get the @@ -179,6 +180,7 @@ type FileUploadMutations { called to register the completed upload and create the blob metadata. """ startFileImport(input: StartFileImportInput!): FileUpload! + @hasServerRole(role: SERVER_GUEST) """ Marks the file import flow as completed for that specific job @@ -186,11 +188,12 @@ type FileUploadMutations { Mostly for internal service usage. """ finishFileImport(input: FinishFileImportInput!): Boolean! - @hasScope(scope: "streams:write") } extend type Mutation { fileUploadMutations: FileUploadMutations! + @hasScope(scope: "streams:write") + @hasServerRole(role: SERVER_GUEST) } enum ProjectPendingModelsUpdatedMessageType { diff --git a/packages/server/assets/notifications/typedefs/notificationPreferences.graphql b/packages/server/assets/notifications/typedefs/notificationPreferences.graphql index 0e610d991..ad0c0f2ed 100644 --- a/packages/server/assets/notifications/typedefs/notificationPreferences.graphql +++ b/packages/server/assets/notifications/typedefs/notificationPreferences.graphql @@ -5,4 +5,5 @@ extend type User { extend type Mutation { userNotificationPreferencesUpdate(preferences: JSONObject!): Boolean @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "profile:update") } diff --git a/packages/server/assets/serverinvites/typedefs/serverInvites.graphql b/packages/server/assets/serverinvites/typedefs/serverInvites.graphql index 86a83844d..6902a52f9 100644 --- a/packages/server/assets/serverinvites/typedefs/serverInvites.graphql +++ b/packages/server/assets/serverinvites/typedefs/serverInvites.graphql @@ -39,6 +39,7 @@ extend type Mutation { """ streamInviteUse(accept: Boolean!, streamId: String!, token: String!): Boolean! @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "profile:write") @deprecated( reason: "Part of the old API surface and will be removed in the future. Use ProjectInviteMutations.use instead." ) @@ -77,6 +78,7 @@ extend type Query { isn't specified, the server will look for any valid invite. """ streamInvite(streamId: String!, token: String): PendingStreamCollaborator + @hasScope(scope: "profile:read") @deprecated( reason: "Part of the old API surface and will be removed in the future. Use Query.projectInvite instead." ) @@ -86,6 +88,7 @@ extend type Query { isn't specified, the server will look for any valid invite. """ projectInvite(projectId: String!, token: String): PendingStreamCollaborator + @hasScope(scope: "profile:read") """ Get all invitations to streams that the active user has diff --git a/packages/server/assets/stats/typedefs/stats.gql b/packages/server/assets/stats/typedefs/stats.gql index 98c29eb0b..5b9478006 100644 --- a/packages/server/assets/stats/typedefs/stats.gql +++ b/packages/server/assets/stats/typedefs/stats.gql @@ -1,5 +1,8 @@ extend type Query { - serverStats: ServerStats! @deprecated(reason: "use admin.serverStatistics instead") + serverStats: ServerStats! + @deprecated(reason: "use admin.serverStatistics instead") + @hasScope(scope: "server:stats") + @hasServerRole(role: SERVER_ADMIN) } type ServerStats { diff --git a/packages/server/assets/workspacesCore/typedefs/regions.graphql b/packages/server/assets/workspacesCore/typedefs/regions.graphql index 34688e12d..7bcf75ff8 100644 --- a/packages/server/assets/workspacesCore/typedefs/regions.graphql +++ b/packages/server/assets/workspacesCore/typedefs/regions.graphql @@ -11,6 +11,8 @@ extend type WorkspaceMutations { Set the default region where project data will be stored. Only available to admins. """ setDefaultRegion(workspaceId: String!, regionKey: String!): Workspace! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "workspace:update") } extend type WorkspaceProjectMutations { @@ -23,4 +25,5 @@ extend type WorkspaceProjectMutations { moveToRegion(projectId: String!, regionKey: String!): String! @hasServerRole(role: SERVER_ADMIN) @hasStreamRole(role: STREAM_OWNER) + @hasScope(scope: "streams:write") } diff --git a/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql b/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql index eeddd9049..0d08e3228 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql @@ -17,11 +17,9 @@ type WorkspaceJoinRequestMutations { approve(input: ApproveWorkspaceJoinRequestInput!): Boolean! @hasServerRole(role: SERVER_USER) @hasScope(scope: "workspace:update") - @hasWorkspaceRole(role: ADMIN) deny(input: DenyWorkspaceJoinRequestInput!): Boolean! @hasServerRole(role: SERVER_USER) @hasScope(scope: "workspace:update") - @hasWorkspaceRole(role: ADMIN) } type LimitedWorkspaceJoinRequest { diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index 840754fbd..95614e9de 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -11,6 +11,7 @@ extend type Query { Find workspaces a given user email can use SSO to sign with """ workspaceSsoByEmail(email: String!): [LimitedWorkspace!]! + @hasScope(scope: "workspace:read") """ Look for an invitation to a workspace, for the current user (authed or not). @@ -23,7 +24,8 @@ extend type Query { workspaceId: String token: String options: WorkspaceInviteLookupOptions - ): PendingWorkspaceCollaborator + ): PendingWorkspaceCollaborator @hasScope(scope: "profile:read") + """ Validates the slug, to make sure it contains only valid characters and its not taken. """ @@ -149,21 +151,30 @@ type WorkspaceMutations { updateRole(input: WorkspaceRoleUpdateInput!): Workspace! @hasScope(scope: "workspace:update") @hasServerRole(role: SERVER_USER) - leave(id: ID!): Boolean! @hasServerRole(role: SERVER_GUEST) + leave(id: ID!): Boolean! + @hasScope(scope: "profile:write") + @hasServerRole(role: SERVER_GUEST) addDomain(input: AddDomainToWorkspaceInput!): Workspace! @hasScope(scope: "workspace:update") deleteDomain(input: WorkspaceDomainDeleteInput!): Workspace! @hasScope(scope: "workspace:update") - deleteSsoProvider(workspaceId: String!): Boolean! - invites: WorkspaceInviteMutations! - projects: WorkspaceProjectMutations! @hasServerRole(role: SERVER_USER) + deleteSsoProvider(workspaceId: String!): Boolean! @hasScope(scope: "workspace:update") + invites: WorkspaceInviteMutations! @hasScope(scope: "users:invite") + projects: WorkspaceProjectMutations! + @hasScope(scope: "streams:write") + @hasServerRole(role: SERVER_USER) updateCreationState(input: WorkspaceCreationStateInput!): Boolean! + @hasScope(scope: "workspace:update") updateEmbedOptions(input: WorkspaceUpdateEmbedOptionsInput!): WorkspaceEmbedOptions! + @hasScope(scope: "workspace:update") """ Dismiss a workspace from the discoverable list, behind the scene a join request is created with the status "dismissed" """ - dismiss(input: WorkspaceDismissInput!): Boolean! @hasServerRole(role: SERVER_USER) + dismiss(input: WorkspaceDismissInput!): Boolean! + @hasServerRole(role: SERVER_USER) + @hasScope(scope: "workspace:update") requestToJoin(input: WorkspaceRequestToJoinInput!): Boolean! + @hasScope(scope: "profile:write") @hasServerRole(role: SERVER_USER) } @@ -246,13 +257,10 @@ input WorkspaceInviteResendInput { type WorkspaceInviteMutations { create(workspaceId: String!, input: WorkspaceInviteCreateInput!): Workspace! - @hasScope(scope: "users:invite") batchCreate(workspaceId: String!, input: [WorkspaceInviteCreateInput!]!): Workspace! - @hasScope(scope: "users:invite") use(input: WorkspaceInviteUseInput!): Boolean! - resend(input: WorkspaceInviteResendInput!): Boolean! @hasScope(scope: "users:invite") + resend(input: WorkspaceInviteResendInput!): Boolean! cancel(workspaceId: String!, inviteId: String!): Workspace! - @hasScope(scope: "users:invite") @hasServerRole(role: SERVER_USER) } @@ -293,14 +301,16 @@ type Workspace { limit: Int! = 25 cursor: String filter: WorkspaceTeamFilter - ): WorkspaceCollaboratorCollection! - teamByRole: WorkspaceTeamByRole! + ): WorkspaceCollaboratorCollection! @hasScope(scope: "users:read") + teamByRole: WorkspaceTeamByRole! @hasScope(scope: "users:read") """ Only available to workspace owners/members """ invitedTeam( filter: PendingWorkspaceCollaboratorsFilter - ): [PendingWorkspaceCollaborator!] @hasWorkspaceRole(role: MEMBER) + ): [PendingWorkspaceCollaborator!] + @hasWorkspaceRole(role: MEMBER) + @hasScope(scope: "users:read") projects( limit: Int! = 25 cursor: String @@ -709,8 +719,11 @@ input AdminAccessToWorkspaceFeatureInput { extend type AdminMutations { updateWorkspacePlan(input: AdminUpdateWorkspacePlanInput!): Boolean! + @hasScope(scope: "workspace:update") giveAccessToWorkspaceFeature(input: AdminAccessToWorkspaceFeatureInput!): Boolean! + @hasScope(scope: "workspace:update") removeAccessToWorkspaceFeature(input: AdminAccessToWorkspaceFeatureInput!): Boolean! + @hasScope(scope: "workspace:update") } extend type ActiveUserMutations { diff --git a/packages/server/modules/auth/tests/apps.graphql.spec.ts b/packages/server/modules/auth/tests/apps.graphql.spec.ts index 5e40785ce..a79ae5e22 100644 --- a/packages/server/modules/auth/tests/apps.graphql.spec.ts +++ b/packages/server/modules/auth/tests/apps.graphql.spec.ts @@ -133,7 +133,7 @@ describe('GraphQL @apps-api', () => { const res = await sendRequest(null, { query, variables }) expect(res).to.be.json expect(res.body.errors).to.exist - expect(res.body.errors[0].extensions?.code).to.equal('FORBIDDEN') + expect(res.body.errors[0].extensions?.code).to.equal('UNAUTHORIZED') }) it('Should get app info', async () => { @@ -217,7 +217,7 @@ describe('GraphQL @apps-api', () => { const query = `mutation del { appDelete( appId: "${testAppId}" ) }` const res = await sendRequest(null, { query }) expect(res.body.errors).to.exist - expect(res.body.errors[0].extensions?.code).to.equal('FORBIDDEN') + expect(res.body.errors[0].extensions?.code).to.equal('UNAUTHORIZED') const res2 = await sendRequest(testToken2, { query }) expect(res2.body.errors).to.exist diff --git a/packages/server/modules/comments/tests/comments.graph.spec.ts b/packages/server/modules/comments/tests/comments.graph.spec.ts index fef0b0815..981bf718e 100644 --- a/packages/server/modules/comments/tests/comments.graph.spec.ts +++ b/packages/server/modules/comments/tests/comments.graph.spec.ts @@ -139,7 +139,7 @@ const testForbiddenResponse = ( expect(result.errors, 'This should have failed').to.exist expect(result.errors!.length).to.be.above(0) expect(result.errors![0].extensions!.code).to.match( - /(STREAM_INVALID_ACCESS_ERROR|FORBIDDEN|UNAUTHORIZED_ACCESS_ERROR)/ + /(STREAM_INVALID_ACCESS_ERROR|FORBIDDEN|UNAUTHORIZED)/ ) } diff --git a/packages/server/modules/core/graph/directives/hasScope.ts b/packages/server/modules/core/graph/directives/hasScope.ts index 0d00e3626..4f867b813 100644 --- a/packages/server/modules/core/graph/directives/hasScope.ts +++ b/packages/server/modules/core/graph/directives/hasScope.ts @@ -13,7 +13,8 @@ export const hasScope: GraphqlDirectiveBuilder = () => { return { typeDefs: ` """ - Ensure that the active user's access token has the specified scope allowed for it + Ensure that if there is a token, the token has the specified scope allowed for it + It does not ensure that the token exists, for that, use @hasServerRole """ directive @${directiveName}(scope: String!) on FIELD_DEFINITION `, @@ -27,8 +28,9 @@ export const hasScope: GraphqlDirectiveBuilder = () => { const { resolve = defaultFieldResolver } = fieldConfig fieldConfig.resolve = async function (...args) { const context = args[2] + const token = context.token const currentScopes = context.scopes - await validateScopes(currentScopes, requiredScope) + if (token) await validateScopes(currentScopes, requiredScope) const data = await resolve.apply(this, args) return data @@ -48,7 +50,8 @@ export const hasScopes: GraphqlDirectiveBuilder = () => { return { typeDefs: ` """ - Ensure that the user's access token has all of the specified scopes allowed for it + Ensure that if there is a token, the token has all of the specified scopes allowed for it + It does not ensure that the token exists, for that, use @hasServerRole """ directive @${directiveName}(scopes: [String]!) on FIELD_DEFINITION `, @@ -63,13 +66,15 @@ export const hasScopes: GraphqlDirectiveBuilder = () => { fieldConfig.resolve = async function (...args) { const context = args[2] + const token = context.token const currentScopes = context.scopes - await Promise.all( - requiredScopes.map((requiredScope: string) => - validateScopes(currentScopes, requiredScope) + if (token) + await Promise.all( + requiredScopes.map((requiredScope: string) => + validateScopes(currentScopes, requiredScope) + ) ) - ) const data = await resolve.apply(this, args) return data diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 7a5f44df0..20698a793 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -10333,6 +10333,20 @@ export type RequestToJoinWorkspaceMutationVariables = Exact<{ export type RequestToJoinWorkspaceMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', requestToJoin: boolean } }; +export type ApproveJoinRequestMutationVariables = Exact<{ + input: ApproveWorkspaceJoinRequestInput; +}>; + + +export type ApproveJoinRequestMutation = { __typename?: 'Mutation', workspaceJoinRequestMutations: { __typename?: 'WorkspaceJoinRequestMutations', approve: boolean } }; + +export type DenyJoinRequestMutationVariables = Exact<{ + input: DenyWorkspaceJoinRequestInput; +}>; + + +export type DenyJoinRequestMutation = { __typename?: 'Mutation', workspaceJoinRequestMutations: { __typename?: 'WorkspaceJoinRequestMutations', deny: boolean } }; + export type GetWorkspaceWithJoinRequestsQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; filter?: InputMaybe; @@ -11342,6 +11356,8 @@ export const OnWorkspaceProjectsUpdatedDocument = {"kind":"Document","definition export const OnWorkspaceUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnWorkspaceUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceSlug"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceSlug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode; export const DismissWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"dismissWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDismissInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dismiss"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode; export const RequestToJoinWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"requestToJoinWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceRequestToJoinInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requestToJoin"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode; +export const ApproveJoinRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"approveJoinRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApproveWorkspaceJoinRequestInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceJoinRequestMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"approve"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode; +export const DenyJoinRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"denyJoinRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DenyWorkspaceJoinRequestInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceJoinRequestMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deny"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode; export const GetWorkspaceWithJoinRequestsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceWithJoinRequests"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"AdminWorkspaceJoinRequestFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"adminWorkspacesJoinRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}}]} as unknown as DocumentNode; export const GetWorkspaceWithSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceWithSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"billingInterval"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"editors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"available"}},{"kind":"Field","name":{"kind":"Name","value":"assigned"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"assigned"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}}]} as unknown as DocumentNode; export const GetWorkspacePlanUsageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspacePlanUsage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"usage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectCount"}},{"kind":"Field","name":{"kind":"Name","value":"modelCount"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}}]} as unknown as DocumentNode; diff --git a/packages/server/modules/core/scopes.ts b/packages/server/modules/core/scopes.ts index 1339a9a87..cc1f7a831 100644 --- a/packages/server/modules/core/scopes.ts +++ b/packages/server/modules/core/scopes.ts @@ -18,6 +18,11 @@ export default [ description: 'Read your profile information.', public: true }, + { + name: Scopes.Profile.Write, + description: 'Make actions on your profile.', + public: true + }, { name: Scopes.Profile.Email, description: 'Read the email address you registered with.', diff --git a/packages/server/modules/core/tests/batchCommits.spec.ts b/packages/server/modules/core/tests/batchCommits.spec.ts index 1a1a51233..bace2a8fd 100644 --- a/packages/server/modules/core/tests/batchCommits.spec.ts +++ b/packages/server/modules/core/tests/batchCommits.spec.ts @@ -323,9 +323,7 @@ describe('Batch commits', () => { myCommits.map((c) => c.id) ) - expect(result).to.haveGraphQLErrors( - 'Your auth token does not have the required scope' - ) + expect(result).to.haveGraphQLErrors('Must provide an auth token') }) }) }) diff --git a/packages/server/modules/core/tests/favoriteStreams.spec.ts b/packages/server/modules/core/tests/favoriteStreams.spec.ts index 1ec2646be..ddb3de587 100644 --- a/packages/server/modules/core/tests/favoriteStreams.spec.ts +++ b/packages/server/modules/core/tests/favoriteStreams.spec.ts @@ -195,7 +195,9 @@ describe('Favorite streams', () => { expect(result.data!.streamFavorite).to.not.be.ok expect(result.errors).to.have.lengthOf(1) - expect(result.errors!.at(0)!.message).to.contain("doesn't have access") + expect(result.errors!.at(0)!.message).to.contain( + "User doesn't have access to the specified stream" + ) }) describe('and favorited', () => { diff --git a/packages/server/modules/core/tests/graph.spec.ts b/packages/server/modules/core/tests/graph.spec.ts index 49152255f..f41d9873f 100644 --- a/packages/server/modules/core/tests/graph.spec.ts +++ b/packages/server/modules/core/tests/graph.spec.ts @@ -132,6 +132,7 @@ const changeUserRole = changeUserRoleFactory({ 'test token user A', [ Scopes.Server.Setup, + Scopes.Server.Stats, Scopes.Streams.Read, Scopes.Streams.Write, Scopes.Users.Read, @@ -139,7 +140,9 @@ const changeUserRole = changeUserRoleFactory({ Scopes.Tokens.Write, Scopes.Tokens.Read, Scopes.Profile.Read, - Scopes.Profile.Email + Scopes.Profile.Email, + Scopes.Profile.Write, + Scopes.Profile.Delete ] )}` @@ -161,7 +164,8 @@ const changeUserRole = changeUserRoleFactory({ Scopes.Tokens.Write, Scopes.Tokens.Read, Scopes.Profile.Read, - Scopes.Profile.Email + Scopes.Profile.Write, + Scopes.Profile.Delete ] )}` userC = await createTestUser({ @@ -181,7 +185,8 @@ const changeUserRole = changeUserRoleFactory({ Scopes.Tokens.Write, Scopes.Tokens.Read, Scopes.Profile.Read, - Scopes.Profile.Email + Scopes.Profile.Email, + Scopes.Profile.Delete ] )}` @@ -336,7 +341,7 @@ const changeUserRole = changeUserRoleFactory({ it('Should create some api tokens', async () => { const res1 = await sendRequest(tokenUserA, { query: - 'mutation { apiTokenCreate(token: {name:"Token 1", scopes: ["streams:read", "users:read", "tokens:read"]}) }' + 'mutation { apiTokenCreate(token: {name:"Token 1", scopes: ["streams:read", "users:read", "tokens:read", "profile:read"]}) }' }) expect(res1).to.be.json expect(res1.body.errors).to.not.exist @@ -509,7 +514,7 @@ const changeUserRole = changeUserRoleFactory({ }) describe('User deletion', () => { - it('Only admins can delete user', async () => { + it('does not allow the endpoint to be used by non-admins', async () => { const userDelete = await createTestUser({ id: '', name: 'delete', @@ -1024,7 +1029,7 @@ const changeUserRole = changeUserRoleFactory({ expect(res3).to.be.json expect(res3.body.errors).to.exist - expect(res3.body.errors[0].extensions.code).to.equal('FORBIDDEN') + expect(res3.body.errors[0].extensions.code).to.equal('UNAUTHORIZED') }) it('Should delete a commit', async () => { @@ -1995,6 +2000,7 @@ const changeUserRole = changeUserRoleFactory({ Scopes.Tokens.Read, Scopes.Profile.Read, Scopes.Profile.Email, + Scopes.Profile.Delete, Scopes.Apps.Read, Scopes.Apps.Write, Scopes.Users.Invite diff --git a/packages/server/modules/core/tests/integration/admin.graph.spec.ts b/packages/server/modules/core/tests/integration/admin.graph.spec.ts index a30523b36..0e4af2c75 100644 --- a/packages/server/modules/core/tests/integration/admin.graph.spec.ts +++ b/packages/server/modules/core/tests/integration/admin.graph.spec.ts @@ -16,7 +16,7 @@ const testForbiddenResponse = ( expect(result.errors, 'This should have failed').to.exist expect(result.errors!.length).to.be.above(0) expect(result.errors![0].extensions!.code, JSON.stringify(result.errors)).to.match( - /(STREAM_INVALID_ACCESS_ERROR|FORBIDDEN|UNAUTHORIZED_ACCESS_ERROR)/ + /(STREAM_INVALID_ACCESS_ERROR|FORBIDDEN|UNAUTHORIZED)/ ) } diff --git a/packages/server/modules/core/tests/usersGraphql.spec.ts b/packages/server/modules/core/tests/usersGraphql.spec.ts index 62b3fae79..8619dd6bf 100644 --- a/packages/server/modules/core/tests/usersGraphql.spec.ts +++ b/packages/server/modules/core/tests/usersGraphql.spec.ts @@ -100,9 +100,7 @@ describe('Users (GraphQL)', () => { const results = await getOtherUser(apollo, { id: otherGuy.id }) expect(results.data?.otherUser).to.be.null - expect(results).to.haveGraphQLErrors( - 'Your auth token does not have the required scope' - ) + expect(results).to.haveGraphQLErrors('Must provide an auth token') }) }) diff --git a/packages/server/modules/emails/tests/verifications.spec.ts b/packages/server/modules/emails/tests/verifications.spec.ts index 95d7445e2..71c203a57 100644 --- a/packages/server/modules/emails/tests/verifications.spec.ts +++ b/packages/server/modules/emails/tests/verifications.spec.ts @@ -195,7 +195,7 @@ describe('Email verifications @emails', () => { it('cant request an account verification', async () => { const result = await requestVerification(apollo) - expect(result).to.haveGraphQLErrors('must provide an auth token') + expect(result).to.haveGraphQLErrors('Must provide an auth token') expect(result.data?.requestVerification).to.not.be.ok }) diff --git a/packages/server/modules/fileuploads/tests/e2e/presigned.graph.spec.ts b/packages/server/modules/fileuploads/tests/e2e/presigned.graph.spec.ts index 99cfd1f4a..b09a9b2cb 100644 --- a/packages/server/modules/fileuploads/tests/e2e/presigned.graph.spec.ts +++ b/packages/server/modules/fileuploads/tests/e2e/presigned.graph.spec.ts @@ -20,7 +20,7 @@ const testForbiddenResponse = ( expect(result.errors, 'This should have failed').to.exist expect(result.errors!.length).to.be.above(0) expect(result.errors![0].extensions!.code, JSON.stringify(result.errors)).to.match( - /(STREAM_INVALID_ACCESS_ERROR|FORBIDDEN|UNAUTHORIZED_ACCESS_ERROR)/ + /(STREAM_INVALID_ACCESS_ERROR|FORBIDDEN|UNAUTHORIZED)/ ) } diff --git a/packages/server/modules/fileuploads/tests/integration/fileuploads.spec.ts b/packages/server/modules/fileuploads/tests/integration/fileuploads.spec.ts index 95f3b75b5..4f5780d39 100644 --- a/packages/server/modules/fileuploads/tests/integration/fileuploads.spec.ts +++ b/packages/server/modules/fileuploads/tests/integration/fileuploads.spec.ts @@ -66,7 +66,7 @@ describe('FileUploads @fileuploads integration', () => { ;({ token: userOneToken } = await createToken({ userId: userOne.id, name: 'test token', - scopes: [Scopes.Streams.Write] + scopes: [Scopes.Streams.Write, Scopes.Streams.Read] })) }) diff --git a/packages/server/modules/fileuploads/tests/integration/results.graphql.spec.ts b/packages/server/modules/fileuploads/tests/integration/results.graphql.spec.ts index 55a96b8b4..ca0b26340 100644 --- a/packages/server/modules/fileuploads/tests/integration/results.graphql.spec.ts +++ b/packages/server/modules/fileuploads/tests/integration/results.graphql.spec.ts @@ -129,7 +129,7 @@ const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags() }) describe('Receive results from file import service', async () => { - it('should 403 if no auth token is provided', async () => { + it('should 401 if no auth token is provided', async () => { const sucessPayload = { projectId: projectOneId, jobId: jobOneId, @@ -147,8 +147,8 @@ const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags() expect(haveErrors(gqlResponse)) expect(gqlResponse.body).to.nested.include({ - 'errors[0].extensions.code': 'FORBIDDEN', - 'errors[0].extensions.statusCode': 403 + 'errors[0].extensions.code': 'UNAUTHORIZED', + 'errors[0].extensions.statusCode': 401 }) }) diff --git a/packages/server/modules/shared/errors/index.ts b/packages/server/modules/shared/errors/index.ts index d374585f1..9ea7355c8 100644 --- a/packages/server/modules/shared/errors/index.ts +++ b/packages/server/modules/shared/errors/index.ts @@ -31,7 +31,7 @@ export class LogicError extends BaseError { * Aka NonAuthorizedError or NotAuthorizedError */ export class UnauthorizedError extends BaseError { - static code = 'UNAUTHORIZED_ACCESS_ERROR' + static code = 'UNAUTHORIZED' static defaultMessage = 'Attempted unauthorized access to data' static statusCode = 401 } diff --git a/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts b/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts index 9d7b77ee1..567692b31 100644 --- a/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts +++ b/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts @@ -45,7 +45,12 @@ import { buildBasicTestModel, buildBasicTestProject } from '@/modules/core/tests/helpers/creation' -import { BadRequestError, ForbiddenError, NotFoundError } from '@/modules/shared/errors' +import { + BadRequestError, + ForbiddenError, + NotFoundError, + UnauthorizedError +} from '@/modules/shared/errors' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import type { FactoryResultOf } from '@/modules/shared/helpers/factory' import { SavedViewVisibility } from '@/modules/viewer/domain/types/savedViews' @@ -356,12 +361,12 @@ const fakeViewerState = (overrides?: PartialDeep { describe('auth policy checks', () => { - it('should fail with ForbiddenError if user is not logged in', async () => { + it('should fail with Unauthorized if user is not logged in', async () => { const res = await createSavedView( buildCreateInput({ projectId: myProject.id, resourceIdString: 'abc' }), { authUserId: null } ) - expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code }) + expect(res).to.haveGraphQLErrors({ code: UnauthorizedError.code }) expect(res.data?.projectMutations.savedViewMutations.createView).to.not.be.ok }) diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts index bfe063dd8..cde9d6219 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts @@ -12,6 +12,7 @@ import { createWorkspaceSeatFactory, getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' +import { authorizeResolver } from '@/modules/shared' import { commandFactory } from '@/modules/shared/command' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { getEventBus } from '@/modules/shared/services/eventBus' @@ -50,6 +51,7 @@ import { import type { WorkspaceJoinRequestStatus } from '@/modules/workspacesCore/domain/types' import type { WorkspaceJoinRequestGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes' import { withOperationLogging } from '@/observability/domain/businessLogging' +import { Roles } from '@speckle/shared' const eventBus = getEventBus() @@ -155,6 +157,13 @@ export default FF_WORKSPACES_MODULE_ENABLED targetUserId }) + await authorizeResolver( + ctx.userId, + workspaceId, + Roles.Workspace.Admin, + ctx.resourceAccessRules + ) + const approveWorkspaceJoinRequest = commandFactory({ db, @@ -229,6 +238,14 @@ export default FF_WORKSPACES_MODULE_ENABLED workspaceId, targetUserId }) + + await authorizeResolver( + ctx.userId, + workspaceId, + Roles.Workspace.Admin, + ctx.resourceAccessRules + ) + const denyWorkspaceJoinRequest = commandFactory({ db, operationFactory: ({ db }) => { diff --git a/packages/server/modules/workspaces/tests/helpers/graphql.ts b/packages/server/modules/workspaces/tests/helpers/graphql.ts index e2dd30960..a044ae0ac 100644 --- a/packages/server/modules/workspaces/tests/helpers/graphql.ts +++ b/packages/server/modules/workspaces/tests/helpers/graphql.ts @@ -335,6 +335,22 @@ export const requestToJoinWorkspaceMutation = gql` } ` +export const approveJoinRequestMutation = gql` + mutation approveJoinRequest($input: ApproveWorkspaceJoinRequestInput!) { + workspaceJoinRequestMutations { + approve(input: $input) + } + } +` + +export const denyJoinRequestMutation = gql` + mutation denyJoinRequest($input: DenyWorkspaceJoinRequestInput!) { + workspaceJoinRequestMutations { + deny(input: $input) + } + } +` + export const getWorkspaceWithJoinRequestsQuery = gql` query GetWorkspaceWithJoinRequests( $workspaceId: String! diff --git a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.graph.spec.ts index c0f710fa4..65da6242e 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.graph.spec.ts @@ -1,278 +1,355 @@ -import { db } from '@/db/knex' import { createRandomString } from '@/modules/core/helpers/testHelpers' +import type { BasicTestWorkspace } from '@/modules/workspaces/tests/helpers/creation' import { createTestWorkspace } from '@/modules/workspaces/tests/helpers/creation' +import type { BasicTestUser } from '@/test/authHelper' import { createTestUser, login } from '@/test/authHelper' import { + ApproveJoinRequestDocument, + DenyJoinRequestDocument, DismissWorkspaceDocument, GetActiveUserWithWorkspaceJoinRequestsDocument, + GetWorkspaceTeamDocument, GetWorkspaceWithJoinRequestsDocument, RequestToJoinWorkspaceDocument } from '@/modules/core/graph/generated/graphql' import { beforeEachContext } from '@/test/hooks' import { Roles } from '@speckle/shared' import { expect } from 'chai' -import { upsertWorkspaceRoleFactory } from '@/modules/workspaces/repositories/workspaces' - -before(async () => { - await beforeEachContext() -}) describe('WorkspaceJoinRequests GQL', () => { - describe('Workspace.adminWorkspacesJoinRequests', () => { - it('should return the workspace join requests for the admin', async () => { - const admin = await createTestUser({ + let admin: BasicTestUser + let user1: BasicTestUser + let user2: BasicTestUser + let user3: BasicTestUser + let workspace1: BasicTestWorkspace + let workspace2: BasicTestWorkspace + let dismissedWorkspace: BasicTestWorkspace + let workspaceAutoJoin: BasicTestWorkspace + + before(async () => { + await beforeEachContext() + ;[admin, user1, user2, user3] = await Promise.all([ + createTestUser({ name: 'admin user', role: Roles.Server.User, email: `${createRandomString()}@example.org`, verified: true - }) - - const user1 = await createTestUser({ + }), + createTestUser({ name: 'user 1', role: Roles.Server.User, email: `${createRandomString()}@example.org`, verified: true - }) - const user2 = await createTestUser({ + }), + createTestUser({ name: 'user 2', role: Roles.Server.User, email: `${createRandomString()}@example.org`, verified: true + }), + createTestUser({ + name: 'user 3', + role: Roles.Server.User, + email: `${createRandomString()}@example.org`, + verified: true }) + ]) + ;[workspace1, dismissedWorkspace, workspace2, workspaceAutoJoin] = + await Promise.all([ + await createTestWorkspace( + { + id: createRandomString(), + name: 'Workspace 1', + ownerId: admin.id, + description: '', + discoverabilityEnabled: true + }, + admin, + { domain: 'example.org' } + ), + await createTestWorkspace( + { + id: createRandomString(), + name: 'should not be visible', + ownerId: admin.id, + description: '', + discoverabilityEnabled: true + }, + admin, + { + domain: 'example.org' + } + ), + await createTestWorkspace( + { + id: createRandomString(), + name: 'Workspace 2', + ownerId: admin.id, + description: '', + discoverabilityEnabled: true + }, + admin, + { domain: 'example.org' } + ), + await createTestWorkspace( + { + id: createRandomString(), + name: 'Worksapce autojoin', + ownerId: admin.id, + description: '', + discoverabilityEnabled: true, + discoverabilityAutoJoinEnabled: true + }, + admin, + { + domain: 'example.org' + } + ) + ]) + }) - const workspace1 = { - id: createRandomString(), - name: 'Workspace 1', - ownerId: admin.id, - description: '', - discoverabilityEnabled: true - } - await createTestWorkspace(workspace1, admin, { domain: 'example.org' }) - - const dismissedWorkspace = { - id: createRandomString(), - name: 'should not be visible', - ownerId: admin.id, - description: '', - discoverabilityEnabled: true - } - await createTestWorkspace(dismissedWorkspace, admin, { domain: 'example.org' }) - - const workspace2 = { - id: createRandomString(), - name: 'Workspace 2', - ownerId: admin.id, - description: '', - discoverabilityEnabled: true - } - await createTestWorkspace(workspace2, admin, { domain: 'example.org' }) - - const nobodyWorkspace = { - id: createRandomString(), - name: 'nobody', - ownerId: admin.id, - description: '', - discoverabilityEnabled: true - } - await createTestWorkspace(nobodyWorkspace, admin, { domain: 'example.org' }) - - const nonAdminWorkspace = { - id: createRandomString(), - name: 'nonadmin', - ownerId: admin.id, - description: '', - discoverabilityEnabled: true - } - await createTestWorkspace(nonAdminWorkspace, admin, { domain: 'example.org' }) - await upsertWorkspaceRoleFactory({ db })({ - userId: admin.id, - workspaceId: nonAdminWorkspace.id, - role: Roles.Workspace.Member, - createdAt: new Date() - }) - + describe('Workspace.adminWorkspacesJoinRequests', () => { + it('allows users to request joining a workspace', async () => { // User1 requests to join workspace1 const sessionUser1 = await login(user1) - const joinReq1 = await sessionUser1.execute(RequestToJoinWorkspaceDocument, { - input: { - workspaceId: workspace1.id - } - }) - expect(joinReq1).to.not.haveGraphQLErrors() - - // User2 requests to join workspace2 - const sessionUser2 = await login(user2) - const joinReq2 = await sessionUser2.execute(RequestToJoinWorkspaceDocument, { - input: { - workspaceId: workspace2.id - } - }) - expect(joinReq2).to.not.haveGraphQLErrors() - - // User requests to join dismissedWorkspace - const joinReqDismissed = await sessionUser2.execute( + await sessionUser1.execute( RequestToJoinWorkspaceDocument, - { - input: { - workspaceId: dismissedWorkspace.id - } - } + { input: { workspaceId: workspace1.id } }, + { assertNoErrors: true } ) - expect(joinReqDismissed).to.not.haveGraphQLErrors() - const dismissReq = await sessionUser2.execute(DismissWorkspaceDocument, { - input: { - workspaceId: dismissedWorkspace.id - } - }) - expect(dismissReq).to.not.haveGraphQLErrors() + // admin logs in const sessionAdmin = await login(admin) const workspace1Res = await sessionAdmin.execute( GetWorkspaceWithJoinRequestsDocument, - { - workspaceId: workspace1.id - } + { workspaceId: workspace1.id }, + { assertNoErrors: true } ) - expect(workspace1Res).to.not.haveGraphQLErrors() - const { items: items1, totalCount: totalCount1 } = + // has one join request + const { items: items, totalCount: totalCount } = workspace1Res.data!.workspace!.adminWorkspacesJoinRequests! - expect(totalCount1).to.equal(1) + expect(totalCount).to.equal(1) + expect(items).to.have.length(1) + expect(items[0].status).to.equal('pending') + expect(items[0].workspace.id).to.equal(workspace1.id) + expect(items[0].user.id).to.equal(user1.id) + }) - expect(items1).to.have.length(1) - expect(items1[0].status).to.equal('pending') - expect(items1[0].workspace.id).to.equal(workspace1.id) - expect(items1[0].user.id).to.equal(user1.id) - - const workspace2Res = await sessionAdmin.execute( - GetWorkspaceWithJoinRequestsDocument, - { - workspaceId: workspace2.id - } + it('has the ability to dismiss a join request', async () => { + // User2 requests to join dismissedWorkspace + const sessionUser2 = await login(user2) + await sessionUser2.execute( + RequestToJoinWorkspaceDocument, + { input: { workspaceId: dismissedWorkspace.id } }, + { assertNoErrors: true } ) - expect(workspace2Res).to.not.haveGraphQLErrors() - const { items: items2, totalCount: totalCount2 } = - workspace2Res.data!.workspace!.adminWorkspacesJoinRequests! + // admins sees a request + const sessionAdmin = await login(admin) + const joinRequests = await sessionAdmin.execute( + GetWorkspaceWithJoinRequestsDocument, + { workspaceId: dismissedWorkspace.id }, + { assertNoErrors: true } + ) + const { workspace: joinsWorkspace2 } = joinRequests.data! + expect(joinsWorkspace2!.adminWorkspacesJoinRequests!.totalCount).to.equal(1) - expect(totalCount2).to.equal(1) - - expect(items2).to.have.length(1) - expect(items2[0].status).to.equal('pending') - expect(items2[0].workspace.id).to.equal(workspace2.id) - expect(items2[0].user.id).to.equal(user2.id) + // user2 cancels the request + await sessionUser2.execute( + DismissWorkspaceDocument, + { input: { workspaceId: dismissedWorkspace.id } }, + { assertNoErrors: true } + ) + // no request for admin const workspaceDismissedRes = await sessionAdmin.execute( GetWorkspaceWithJoinRequestsDocument, - { - workspaceId: dismissedWorkspace.id - } + { workspaceId: dismissedWorkspace.id }, + { assertNoErrors: true } ) - expect(workspaceDismissedRes).to.not.haveGraphQLErrors() - const { items: itemsDismissed, totalCount: totalCountDismissed } = - workspaceDismissedRes.data!.workspace!.adminWorkspacesJoinRequests! - - expect(totalCountDismissed).to.equal(0) - expect(itemsDismissed).to.have.length(0) + const { workspace } = workspaceDismissedRes.data! + expect(workspace.adminWorkspacesJoinRequests!.items).to.have.lengthOf(0) + expect(workspace.adminWorkspacesJoinRequests!.totalCount).to.eql(0) }) }) describe('User.workspaceJoinRequests', () => { it('should return the workspace join requests for the user', async () => { - const admin = await createTestUser({ - name: 'admin user', - role: Roles.Server.User, - email: `${createRandomString()}@example.org`, - verified: true - }) - - const user = await createTestUser({ - name: 'user 1', - role: Roles.Server.User, - email: `${createRandomString()}@example.org`, - verified: true - }) - - const workspace1 = { - id: createRandomString(), - name: 'Workspace 1', - ownerId: admin.id, - description: '', - discoverabilityEnabled: true - } - await createTestWorkspace(workspace1, admin, { domain: 'example.org' }) - - const workspace2 = { - id: createRandomString(), - name: 'Workspace 2', - ownerId: admin.id, - description: '', - discoverabilityEnabled: true - } - await createTestWorkspace(workspace2, admin, { domain: 'example.org' }) - - const workspaceDismissed = { - id: createRandomString(), - name: 'should not see', - ownerId: admin.id, - description: '', - discoverabilityEnabled: true - } - await createTestWorkspace(workspaceDismissed, admin, { domain: 'example.org' }) - - const sessionUser = await login(user) - - // User requests to join workspace1 - const joinReq1 = await sessionUser.execute(RequestToJoinWorkspaceDocument, { - input: { - workspaceId: workspace1.id - } - }) - expect(joinReq1).to.not.haveGraphQLErrors() - - // User requests to join workspace2 - const joinReq2 = await sessionUser.execute(RequestToJoinWorkspaceDocument, { - input: { - workspaceId: workspace2.id - } - }) - expect(joinReq2).to.not.haveGraphQLErrors() - - // User requests to join workspaceDismissed - const joinReqDismissed = await sessionUser.execute( + // User requests to join workspace1 and 2 + const sessionUser = await login(user1) + await sessionUser.execute( RequestToJoinWorkspaceDocument, - { - input: { - workspaceId: workspaceDismissed.id - } - } + { input: { workspaceId: workspace1.id } }, + { assertNoErrors: true } + ) + await sessionUser.execute( + RequestToJoinWorkspaceDocument, + { input: { workspaceId: workspace2.id } }, + { assertNoErrors: true } ) - expect(joinReqDismissed).to.not.haveGraphQLErrors() - const dismissReq = await sessionUser.execute(DismissWorkspaceDocument, { - input: { - workspaceId: workspaceDismissed.id - } - }) - expect(dismissReq).to.not.haveGraphQLErrors() const res = await sessionUser.execute( GetActiveUserWithWorkspaceJoinRequestsDocument, - {} + {}, + { assertNoErrors: true } ) - expect(res).to.not.haveGraphQLErrors() - const { items, totalCount } = res.data!.activeUser!.workspaceJoinRequests! expect(totalCount).to.equal(2) - expect(items).to.have.length(2) expect(items[0].status).to.equal('pending') expect(items[0].workspace.id).to.equal(workspace2.id) - expect(items[0].user.id).to.equal(user.id) + expect(items[0].user.id).to.equal(user1.id) expect(items[1].status).to.equal('pending') expect(items[1].workspace.id).to.equal(workspace1.id) - expect(items[1].user.id).to.equal(user.id) + expect(items[1].user.id).to.equal(user1.id) + }) + + it('does not show request that were dissmissed for the user', async () => { + // User requests to join workspaceDismissed + const sessionUser = await login(user2) + await sessionUser.execute( + RequestToJoinWorkspaceDocument, + { input: { workspaceId: dismissedWorkspace.id } }, + { assertNoErrors: true } + ) + + // dismisses it + await sessionUser.execute( + DismissWorkspaceDocument, + { input: { workspaceId: dismissedWorkspace.id } }, + { assertNoErrors: true } + ) + + const res = await sessionUser.execute( + GetActiveUserWithWorkspaceJoinRequestsDocument, + {}, + { assertNoErrors: true } + ) + const { items, totalCount } = res.data!.activeUser!.workspaceJoinRequests! + + expect(totalCount).to.equal(0) + expect(items).to.have.length(0) + }) + }) + + describe('joining a workspace', () => { + it('allows admin accepting a join request to a workspace', async () => { + const sessionAdmin = await login(admin) + const sessionUser = await login(user1) + + // User requests to join workspace1 + await sessionUser.execute( + RequestToJoinWorkspaceDocument, + { input: { workspaceId: workspace1.id } }, + { assertNoErrors: true } + ) + + await sessionAdmin.execute( + ApproveJoinRequestDocument, + { input: { workspaceId: workspace1.id, userId: user1.id } }, + { assertNoErrors: true } + ) + + const res = await sessionAdmin.execute(GetWorkspaceTeamDocument, { + workspaceId: workspace1.id + }) + + const { items, totalCount } = res.data!.workspace.team + + expect(totalCount).to.equal(2) + expect(items).to.have.length(2) + expect(items[0].id).to.equal(user1.id) + }) + + it('allows admin denying a join request to a workspace', async () => { + const sessionAdmin = await login(admin) + const sessionUser = await login(user2) + + // User requests to join workspace1 + await sessionUser.execute( + RequestToJoinWorkspaceDocument, + { input: { workspaceId: workspace2.id } }, + { assertNoErrors: true } + ) + + await sessionAdmin.execute( + DenyJoinRequestDocument, + { input: { workspaceId: workspace2.id, userId: user2.id } }, + { assertNoErrors: true } + ) + + const res = await sessionAdmin.execute(GetWorkspaceTeamDocument, { + workspaceId: workspace2.id + }) + + const { items, totalCount } = res.data!.workspace.team + + expect(totalCount).to.equal(1) + expect(items).to.have.length(1) + }) + + it('doesnt allow the joiner user to hack around their way into a workspace', async () => { + const sessionAdmin = await login(admin) + const sessionUser = await login(user3) + + // User requests to join workspace1 + await sessionUser.execute( + RequestToJoinWorkspaceDocument, + { input: { workspaceId: workspace2.id } }, + { assertNoErrors: true } + ) + + // Accepts himself + const autoAcceptAttempt = await sessionUser.execute( + ApproveJoinRequestDocument, + { input: { workspaceId: workspace2.id, userId: user3.id } }, + { assertNoErrors: false } + ) + + const autoDenyAttempt = await sessionUser.execute( + DenyJoinRequestDocument, + { input: { workspaceId: workspace2.id, userId: user3.id } }, + { assertNoErrors: false } + ) + + const res = await sessionAdmin.execute(GetWorkspaceTeamDocument, { + workspaceId: workspace2.id + }) + + const { items, totalCount } = res.data!.workspace.team + + const AUTH_ERROR = 'You are not authorized to access this resource.' + expect(autoAcceptAttempt).to.haveGraphQLErrors() + expect(autoAcceptAttempt.errors![0].message).to.contain(AUTH_ERROR) + expect(autoDenyAttempt).to.haveGraphQLErrors() + expect(autoDenyAttempt.errors![0].message).to.contain(AUTH_ERROR) + expect(totalCount).to.equal(1) + expect(items).to.have.length(1) + }) + + it('can auto join if admin had previously preconfigured it', async () => { + const sessionAdmin = await login(admin) + const sessionUser = await login(user3) + + // User requests to join workspace1 + await sessionUser.execute( + RequestToJoinWorkspaceDocument, + { input: { workspaceId: workspaceAutoJoin.id } }, + { assertNoErrors: true } + ) + + const res = await sessionAdmin.execute(GetWorkspaceTeamDocument, { + workspaceId: workspaceAutoJoin.id + }) + + const { items, totalCount } = res.data!.workspace.team + + expect(totalCount).to.equal(2) + expect(items).to.have.length(2) + expect(items[0].id).to.equal(user3.id) }) }) }) diff --git a/packages/shared/src/core/constants.ts b/packages/shared/src/core/constants.ts index eda70fbb4..ec07ee062 100644 --- a/packages/shared/src/core/constants.ts +++ b/packages/shared/src/core/constants.ts @@ -112,6 +112,7 @@ export const Scopes = Object.freeze({ }, Profile: { Read: 'profile:read', + Write: 'profile:write', Email: 'profile:email', Delete: 'profile:delete' },