diff --git a/.circleci/config.yml b/.circleci/config.yml index fdedadb86..b6e3e4503 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -333,6 +333,7 @@ jobs: command: server /data --console-address ":9001" # environment: + resource_class: large environment: NODE_ENV: test DATABASE_URL: 'postgres://speckle:speckle@127.0.0.1:5432/speckle2_test' diff --git a/packages/fileimport-service/src/daemon.js b/packages/fileimport-service/src/daemon.js index bfe9e3baa..f4959519a 100644 --- a/packages/fileimport-service/src/daemon.js +++ b/packages/fileimport-service/src/daemon.js @@ -17,6 +17,7 @@ const { spawn } = require('child_process') const ServerAPI = require('../ifc/api') const objDependencies = require('./objDependencies') const { logger } = require('../observability/logging') +const { Scopes } = require('@speckle/shared') const HEALTHCHECK_FILE_PATH = '/tmp/last_successful_query' @@ -97,7 +98,7 @@ async function doTask(task) { const { token } = await serverApi.createToken({ userId: info.userId, name: 'temp upload token', - scopes: ['streams:write', 'streams:read'], + scopes: [Scopes.Streams.Write, Scopes.Streams.Read], lifespan: 1000000 }) tempUserToken = token diff --git a/packages/frontend/src/main/pages/admin/Users.vue b/packages/frontend/src/main/pages/admin/Users.vue index c6c7e252d..fdee0cca6 100644 --- a/packages/frontend/src/main/pages/admin/Users.vue +++ b/packages/frontend/src/main/pages/admin/Users.vue @@ -143,7 +143,8 @@ export default { roleLookupTable: { [Roles.Server.User]: 'User', [Roles.Server.Admin]: 'Admin', - [Roles.Server.ArchivedUser]: 'Archived' + [Roles.Server.ArchivedUser]: 'Archived', + [Roles.Server.Guest]: 'Guest' }, adminUsers: { items: [], diff --git a/packages/server/assets/accessrequests/typedefs/accessrequests.graphql b/packages/server/assets/accessrequests/typedefs/accessrequests.graphql index 2a8d1245d..8fb239038 100644 --- a/packages/server/assets/accessrequests/typedefs/accessrequests.graphql +++ b/packages/server/assets/accessrequests/typedefs/accessrequests.graphql @@ -3,7 +3,7 @@ extend type Query { Get authed user's stream access request """ streamAccessRequest(streamId: String!): StreamAccessRequest - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) } extend type Stream { @@ -21,13 +21,13 @@ extend type Mutation { requestId: String! accept: Boolean! role: StreamRole! = STREAM_CONTRIBUTOR - ): Boolean! @hasRole(role: "server:user") @hasScope(scope: "users:invite") + ): Boolean! @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "users:invite") """ Request access to a specific stream """ streamAccessRequestCreate(streamId: String!): StreamAccessRequest! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "users:invite") } diff --git a/packages/server/assets/activitystream/typedefs/activity.graphql b/packages/server/assets/activitystream/typedefs/activity.graphql index 807e2d4b4..5783cf0a2 100644 --- a/packages/server/assets/activitystream/typedefs/activity.graphql +++ b/packages/server/assets/activitystream/typedefs/activity.graphql @@ -8,7 +8,9 @@ extend type User { before: DateTime cursor: DateTime limit: Int! = 25 - ): ActivityCollection @hasRole(role: "server:user") @hasScope(scope: "users:read") + ): ActivityCollection + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "users:read") """ The user's timeline in chronological order @@ -19,7 +21,7 @@ extend type User { cursor: DateTime limit: Int! = 25 ): ActivityCollection - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScopes(scopes: ["users:read", "streams:read"]) } @@ -33,7 +35,9 @@ extend type LimitedUser { before: DateTime cursor: DateTime limit: Int! = 25 - ): ActivityCollection @hasRole(role: "server:user") @hasScope(scope: "users:read") + ): ActivityCollection + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "users:read") """ The user's timeline in chronological order @@ -44,7 +48,7 @@ extend type LimitedUser { cursor: DateTime limit: Int! = 25 ): ActivityCollection - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScopes(scopes: ["users:read", "streams:read"]) } @@ -58,7 +62,9 @@ extend type Stream { before: DateTime cursor: DateTime limit: Int! = 25 - ): ActivityCollection @hasRole(role: "server:user") @hasScope(scope: "streams:read") + ): ActivityCollection + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "streams:read") } extend type Branch { @@ -71,7 +77,9 @@ extend type Branch { before: DateTime cursor: DateTime limit: Int! = 25 - ): ActivityCollection @hasRole(role: "server:user") @hasScope(scope: "streams:read") + ): ActivityCollection + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "streams:read") } extend type Commit { @@ -84,7 +92,9 @@ extend type Commit { before: DateTime cursor: DateTime limit: Int! = 25 - ): ActivityCollection @hasRole(role: "server:user") @hasScope(scope: "streams:read") + ): ActivityCollection + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "streams:read") } type ActivityCollection { diff --git a/packages/server/assets/auth/typedefs/apps.graphql b/packages/server/assets/auth/typedefs/apps.graphql index 591e06468..d0d30d168 100644 --- a/packages/server/assets/auth/typedefs/apps.graphql +++ b/packages/server/assets/auth/typedefs/apps.graphql @@ -47,13 +47,15 @@ extend type User { Returns the apps you have authorized. """ authorizedApps: [ServerAppListItem] - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "apps:read") """ Returns the apps you have created. """ - createdApps: [ServerApp!] @hasRole(role: "server:user") @hasScope(scope: "apps:read") + createdApps: [ServerApp!] + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "apps:read") } extend type Mutation { @@ -61,28 +63,28 @@ extend type Mutation { Register a new third party application. """ appCreate(app: AppCreateInput!): String! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "apps:write") """ Update an existing third party application. **Note: This will invalidate all existing tokens, refresh tokens and access codes and will require existing users to re-authorize it.** """ appUpdate(app: AppUpdateInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "apps:write") """ Deletes a thirty party application. """ appDelete(appId: String!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "apps:write") """ Revokes (de-authorizes) an application that you have previously authorized. """ appRevokeAccess(appId: String!): Boolean - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "apps:write") } diff --git a/packages/server/assets/comments/typedefs/comments.gql b/packages/server/assets/comments/typedefs/comments.gql index 90302aacd..4244aef30 100644 --- a/packages/server/assets/comments/typedefs/comments.gql +++ b/packages/server/assets/comments/typedefs/comments.gql @@ -324,7 +324,7 @@ type CommentMutations { } extend type Mutation { - commentMutations: CommentMutations! @hasServerRole(role: SERVER_USER) + commentMutations: CommentMutations! @hasServerRole(role: SERVER_GUEST) """ Used for broadcasting real time chat head bubbles and status. Does not persist any info. @@ -334,7 +334,7 @@ extend type Mutation { resourceId: String! data: JSONObject ): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @deprecated(reason: "Use broadcastViewerUserActivity") """ @@ -345,14 +345,14 @@ extend type Mutation { commentId: String! data: JSONObject ): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @deprecated(reason: "Use broadcastViewerUserActivity") """ Creates a comment """ commentCreate(input: CommentCreateInput!): String! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") @deprecated(reason: "Use commentMutations version") @@ -360,7 +360,7 @@ extend type Mutation { Flags a comment as viewed by you (the logged in user). """ commentView(streamId: String!, commentId: String!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") @deprecated(reason: "Use commentMutations version") @@ -372,7 +372,7 @@ extend type Mutation { commentId: String! archived: Boolean! = true ): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") @deprecated(reason: "Use commentMutations version") @@ -380,7 +380,7 @@ extend type Mutation { Edits a comment. """ commentEdit(input: CommentEditInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") @deprecated(reason: "Use commentMutations version") @@ -388,7 +388,7 @@ extend type Mutation { Adds a reply to a comment. """ commentReply(input: ReplyCreateInput!): String! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") @deprecated(reason: "Use commentMutations version") } @@ -457,7 +457,7 @@ extend type Subscription { - for a specific resource/set of resources: pass in a list of resourceIds (commit or object ids); this sub will get called when *any* of the resources provided get a comment. """ commentActivity(streamId: String!, resourceIds: [String]): CommentActivityMessage! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") @deprecated(reason: "Use projectCommentsUpdated") @@ -470,7 +470,7 @@ extend type Subscription { streamId: String! commentId: String! ): CommentThreadActivityMessage! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") @deprecated( reason: "Use projectCommentsUpdated or viewerUserActivityBroadcasted for reply status" diff --git a/packages/server/assets/comments/typedefs/viewer.gql b/packages/server/assets/comments/typedefs/viewer.gql index 0d5b41d79..41f6b0e18 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_USER) + ): Boolean! @hasServerRole(role: SERVER_GUEST) } extend type Subscription { diff --git a/packages/server/assets/core/typedefs/apitoken.graphql b/packages/server/assets/core/typedefs/apitoken.graphql index f2fe6e3cf..187527d85 100644 --- a/packages/server/assets/core/typedefs/apitoken.graphql +++ b/packages/server/assets/core/typedefs/apitoken.graphql @@ -2,7 +2,9 @@ extend type User { """ Returns a list of your personal api tokens. """ - apiTokens: [ApiToken] @hasRole(role: "server:user") @hasScope(scope: "tokens:read") + apiTokens: [ApiToken] + @hasServerRole(role: SERVER_USER) + @hasScope(scope: "tokens:read") } type ApiToken { @@ -26,12 +28,12 @@ extend type Mutation { Creates an personal api token. """ apiTokenCreate(token: ApiTokenCreateInput!): String! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "tokens:write") """ Revokes (deletes) an personal api token. """ apiTokenRevoke(token: String!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "tokens:write") } diff --git a/packages/server/assets/core/typedefs/branchesAndCommits.graphql b/packages/server/assets/core/typedefs/branchesAndCommits.graphql index 4a181bf71..dc0d97469 100644 --- a/packages/server/assets/core/typedefs/branchesAndCommits.graphql +++ b/packages/server/assets/core/typedefs/branchesAndCommits.graphql @@ -48,7 +48,7 @@ type Commit { Will throw an authorization error if active user isn't authorized to see it, for example, if a stream isn't public and the user doesn't have the appropriate rights. """ - stream: Stream! @hasRole(role: "server:user") @hasScope(scope: "streams:read") + stream: Stream! @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") } type BranchCollection { @@ -65,40 +65,40 @@ type CommitCollection { extend type Mutation { branchCreate(branch: BranchCreateInput!): String! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:write") branchUpdate(branch: BranchUpdateInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:write") branchDelete(branch: BranchDeleteInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:write") commitCreate(commit: CommitCreateInput!): String! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:write") commitUpdate(commit: CommitUpdateInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:write") commitReceive(input: CommitReceivedInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") commitDelete(commit: CommitDeleteInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:write") """ Move a batch of commits to a new branch """ commitsMove(input: CommitsMoveInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:write") """ Delete a batch of commits """ commitsDelete(input: CommitsDeleteInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:write") } @@ -108,38 +108,38 @@ extend type Subscription { Subscribe to branch created event """ branchCreated(streamId: String!): JSONObject - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") """ Subscribe to branch updated event. """ branchUpdated(streamId: String!, branchId: String): JSONObject - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") """ Subscribe to branch deleted event """ branchDeleted(streamId: String!): JSONObject - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") """ Subscribe to commit created event """ commitCreated(streamId: String!): JSONObject - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") """ Subscribe to commit updated event. """ commitUpdated(streamId: String!, commitId: String): JSONObject - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") """ Subscribe to commit deleted event """ commitDeleted(streamId: String!): JSONObject - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") } diff --git a/packages/server/assets/core/typedefs/modelsAndVersions.graphql b/packages/server/assets/core/typedefs/modelsAndVersions.graphql index 48c71471a..960bcbba9 100644 --- a/packages/server/assets/core/typedefs/modelsAndVersions.graphql +++ b/packages/server/assets/core/typedefs/modelsAndVersions.graphql @@ -164,11 +164,11 @@ type VersionMutations { extend type Mutation { modelMutations: ModelMutations! - @hasServerRole(role: SERVER_USER) + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:write") versionMutations: VersionMutations! - @hasServerRole(role: SERVER_USER) + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:write") } diff --git a/packages/server/assets/core/typedefs/server.graphql b/packages/server/assets/core/typedefs/server.graphql index 71f73a7a9..db1b00e94 100644 --- a/packages/server/assets/core/typedefs/server.graphql +++ b/packages/server/assets/core/typedefs/server.graphql @@ -15,6 +15,7 @@ type ServerInfo { roles: [Role]! scopes: [Scope]! inviteOnly: Boolean + guestModeEnabled: Boolean! version: String } @@ -37,7 +38,7 @@ type Scope { extend type Mutation { serverInfoUpdate(info: ServerInfoUpdateInput!): Boolean - @hasRole(role: "server:admin") + @hasServerRole(role: SERVER_ADMIN) @hasScope(scope: "server:setup") } @@ -48,4 +49,5 @@ input ServerInfoUpdateInput { adminContact: String termsOfService: String inviteOnly: Boolean + guestModeEnabled: Boolean } diff --git a/packages/server/assets/core/typedefs/streams.graphql b/packages/server/assets/core/typedefs/streams.graphql index 77cbd7b63..97a913449 100644 --- a/packages/server/assets/core/typedefs/streams.graphql +++ b/packages/server/assets/core/typedefs/streams.graphql @@ -10,7 +10,7 @@ extend type Query { Pass in the `query` parameter to search by name, description or ID. """ streams(query: String, limit: Int = 25, cursor: String): StreamCollection - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") """ @@ -23,7 +23,7 @@ extend type Query { visibility: String limit: Int = 25 ): StreamCollection - @hasRole(role: "server:admin") + @hasServerRole(role: SERVER_ADMIN) @deprecated(reason: "use admin.projectList instead") """ @@ -79,7 +79,7 @@ extend type User { authenticated user, then this will only return discoverable streams. """ streams(limit: Int! = 25, cursor: String): StreamCollection! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") """ @@ -87,7 +87,7 @@ extend type User { Note: You can't use this to retrieve another user's favorite streams. """ favoriteStreams(limit: Int! = 25, cursor: String): StreamCollection! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") """ @@ -101,7 +101,7 @@ extend type LimitedUser { Returns all discoverable streams that the user is a collaborator on """ streams(limit: Int! = 25, cursor: String): StreamCollection! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") """ @@ -152,43 +152,43 @@ extend type Mutation { Creates a new stream. """ streamCreate(stream: StreamCreateInput!): String - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "streams:write") """ Updates an existing stream. """ streamUpdate(stream: StreamUpdateInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "streams:write") """ Deletes an existing stream. """ streamDelete(id: String!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "streams:write") - streamsDelete(ids: [String!]): Boolean! @hasRole(role: "server:admin") + streamsDelete(ids: [String!]): Boolean! @hasServerRole(role: SERVER_ADMIN) """ Update permissions of a user on a given stream. """ streamUpdatePermission(permissionParams: StreamUpdatePermissionInput!): Boolean - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "streams:write") """ Revokes the permissions of a user on a given stream. """ streamRevokePermission(permissionParams: StreamRevokePermissionInput!): Boolean - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "streams:write") # Favorite/unfavorite the given stream streamFavorite(streamId: String!, favorited: Boolean!): Stream - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) """ Remove yourself from stream collaborators (not possible for the owner) """ - streamLeave(streamId: String!): Boolean! @hasRole(role: "server:user") + streamLeave(streamId: String!): Boolean! @hasServerRole(role: SERVER_GUEST) } extend type Subscription { @@ -202,7 +202,7 @@ extend type Subscription { **NOTE**: If someone shares a stream with you, this subscription will be triggered with an extra value of `sharedBy` in the payload. """ userStreamAdded: JSONObject - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "profile:read") """ @@ -210,7 +210,7 @@ extend type Subscription { **NOTE**: If someone revokes your permissions on a stream, this subscription will be triggered with an extra value of `revokedBy` in the payload. """ userStreamRemoved: JSONObject - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "profile:read") # @@ -222,14 +222,14 @@ extend type Subscription { Subscribes to stream updated event. Use this in clients/components that pertain only to this stream. """ streamUpdated(streamId: String): JSONObject - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") """ Subscribes to stream deleted event. Use this in clients/components that pertain only to this stream. """ streamDeleted(streamId: String): JSONObject - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") } diff --git a/packages/server/assets/core/typedefs/user.graphql b/packages/server/assets/core/typedefs/user.graphql index 370c8ba5b..ad2d463cf 100644 --- a/packages/server/assets/core/typedefs/user.graphql +++ b/packages/server/assets/core/typedefs/user.graphql @@ -8,7 +8,7 @@ extend type Query { Get the (limited) profile information of another server user """ otherUser(id: String!): LimitedUser - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "users:read") """ @@ -28,8 +28,8 @@ extend type Query { offset: Int! = 0 query: String = null ): AdminUsersListCollection + @hasServerRole(role: SERVER_ADMIN) @deprecated(reason: "use admin.UserList instead") - @hasRole(role: "server:admin") @hasScope(scope: "users:read") """ @@ -154,18 +154,19 @@ extend type Mutation { Delete a user's account. """ userDelete(userConfirmation: UserDeleteInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "profile:delete") adminDeleteUser(userConfirmation: UserDeleteInput!): Boolean! - @hasRole(role: "server:admin") + @hasServerRole(role: SERVER_ADMIN) - userRoleChange(userRoleInput: UserRoleInput!): Boolean! @hasRole(role: "server:admin") + userRoleChange(userRoleInput: UserRoleInput!): Boolean! + @hasServerRole(role: SERVER_ADMIN) """ Various Active User oriented mutations """ - activeUserMutations: ActiveUserMutations! @hasRole(role: "server:user") + activeUserMutations: ActiveUserMutations! @hasServerRole(role: SERVER_GUEST) } input UserRoleInput { diff --git a/packages/server/assets/emails/typedefs/emails.graphql b/packages/server/assets/emails/typedefs/emails.graphql index bb85a7f6c..a0deb8516 100644 --- a/packages/server/assets/emails/typedefs/emails.graphql +++ b/packages/server/assets/emails/typedefs/emails.graphql @@ -9,5 +9,5 @@ extend type Mutation { """ (Re-)send the account verification e-mail """ - requestVerification: Boolean! @hasRole(role: "server:user") + requestVerification: Boolean! @hasServerRole(role: SERVER_GUEST) } diff --git a/packages/server/assets/notifications/typedefs/notificationPreferences.graphql b/packages/server/assets/notifications/typedefs/notificationPreferences.graphql index ccbbc8e67..0e610d991 100644 --- a/packages/server/assets/notifications/typedefs/notificationPreferences.graphql +++ b/packages/server/assets/notifications/typedefs/notificationPreferences.graphql @@ -4,5 +4,5 @@ extend type User { extend type Mutation { userNotificationPreferencesUpdate(preferences: JSONObject!): Boolean - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) } diff --git a/packages/server/assets/serverinvites/typedefs/serverInvites.graphql b/packages/server/assets/serverinvites/typedefs/serverInvites.graphql index 1a8305109..0af142010 100644 --- a/packages/server/assets/serverinvites/typedefs/serverInvites.graphql +++ b/packages/server/assets/serverinvites/typedefs/serverInvites.graphql @@ -3,49 +3,49 @@ extend type Mutation { Invite a new user to the speckle server and return the invite ID """ serverInviteCreate(input: ServerInviteCreateInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "users:invite") """ Invite a new or registered user to the specified stream """ streamInviteCreate(input: StreamInviteCreateInput!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "users:invite") serverInviteBatchCreate(input: [ServerInviteCreateInput!]!): Boolean! - @hasRole(role: "server:admin") + @hasServerRole(role: SERVER_ADMIN) @hasScope(scope: "users:invite") streamInviteBatchCreate(input: [StreamInviteCreateInput!]!): Boolean! - @hasRole(role: "server:admin") + @hasServerRole(role: SERVER_ADMIN) @hasScope(scope: "users:invite") """ Accept or decline a stream invite """ streamInviteUse(accept: Boolean!, streamId: String!, token: String!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) """ Cancel a pending stream invite. Can only be invoked by a stream owner. """ streamInviteCancel(streamId: String!, inviteId: String!): Boolean! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "users:invite") """ Re-send a pending invite """ inviteResend(inviteId: String!): Boolean! - @hasRole(role: "server:admin") + @hasServerRole(role: SERVER_ADMIN) @hasScope(scope: "users:invite") """ Delete a pending invite """ inviteDelete(inviteId: String!): Boolean! - @hasRole(role: "server:admin") + @hasServerRole(role: SERVER_ADMIN) @hasScope(scope: "users:invite") } @@ -66,7 +66,7 @@ extend type Query { Get all invitations to streams that the active user has """ streamInvites: [PendingStreamCollaborator!]! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") } diff --git a/packages/server/assets/webhooks/typedefs/webhooks.graphql b/packages/server/assets/webhooks/typedefs/webhooks.graphql index 8bf12f0a3..d13ea3894 100644 --- a/packages/server/assets/webhooks/typedefs/webhooks.graphql +++ b/packages/server/assets/webhooks/typedefs/webhooks.graphql @@ -1,6 +1,6 @@ extend type Stream { webhooks(id: String): WebhookCollection - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "streams:write") } @@ -9,21 +9,21 @@ extend type Mutation { Creates a new webhook on a stream """ webhookCreate(webhook: WebhookCreateInput!): String! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "streams:write") """ Updates an existing webhook """ webhookUpdate(webhook: WebhookUpdateInput!): String! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "streams:write") """ Deletes an existing webhook """ webhookDelete(webhook: WebhookDeleteInput!): String! - @hasRole(role: "server:user") + @hasServerRole(role: SERVER_USER) @hasScope(scope: "streams:write") } diff --git a/packages/server/modules/activitystream/services/branchActivity.ts b/packages/server/modules/activitystream/services/branchActivity.ts index 6f5635f41..3ffd19f29 100644 --- a/packages/server/modules/activitystream/services/branchActivity.ts +++ b/packages/server/modules/activitystream/services/branchActivity.ts @@ -1,7 +1,10 @@ import { saveActivity } from '@/modules/activitystream/services' import { ActionTypes, ResourceTypes } from '@/modules/activitystream/helpers/types' import { BranchRecord } from '@/modules/core/helpers/types' -import { pubsub, BranchPubsubEvents } from '@/modules/shared' +import { + pubsub, + BranchSubscriptions as BranchPubsubEvents +} from '@/modules/shared/utils/subscriptions' import { BranchDeleteInput, BranchUpdateInput, diff --git a/packages/server/modules/activitystream/services/commentActivity.ts b/packages/server/modules/activitystream/services/commentActivity.ts index e20842529..6ba8c8300 100644 --- a/packages/server/modules/activitystream/services/commentActivity.ts +++ b/packages/server/modules/activitystream/services/commentActivity.ts @@ -14,7 +14,7 @@ import { getViewerResourcesForComment, getViewerResourcesFromLegacyIdentifiers } from '@/modules/core/services/commit/viewerResources' -import { pubsub } from '@/modules/shared' +import { pubsub } from '@/modules/shared/utils/subscriptions' import { CommentSubscriptions, ProjectSubscriptions, diff --git a/packages/server/modules/activitystream/services/commitActivity.ts b/packages/server/modules/activitystream/services/commitActivity.ts index 7b932a134..7341a1d60 100644 --- a/packages/server/modules/activitystream/services/commitActivity.ts +++ b/packages/server/modules/activitystream/services/commitActivity.ts @@ -1,6 +1,9 @@ import { saveActivity } from '@/modules/activitystream/services' import { ActionTypes, ResourceTypes } from '@/modules/activitystream/helpers/types' -import { CommitPubsubEvents, pubsub } from '@/modules/shared' +import { + CommitSubscriptions as CommitPubsubEvents, + pubsub +} from '@/modules/shared/utils/subscriptions' import { CommitCreateInput, CommitReceivedInput, diff --git a/packages/server/modules/activitystream/services/streamActivity.ts b/packages/server/modules/activitystream/services/streamActivity.ts index 198301605..2d9413f12 100644 --- a/packages/server/modules/activitystream/services/streamActivity.ts +++ b/packages/server/modules/activitystream/services/streamActivity.ts @@ -1,7 +1,10 @@ import { saveActivity } from '@/modules/activitystream/services' import { ActionTypes, ResourceTypes } from '@/modules/activitystream/helpers/types' import { StreamRoles } from '@/modules/core/helpers/mainConstants' -import { pubsub, StreamPubsubEvents } from '@/modules/shared' +import { + pubsub, + StreamSubscriptions as StreamPubsubEvents +} from '@/modules/shared/utils/subscriptions' import { StreamCreateInput } from '@/test/graphql/generated/graphql' import { Knex } from 'knex' import { getStreamCollaborators } from '@/modules/core/repositories/streams' diff --git a/packages/server/modules/activitystream/tests/activity.spec.js b/packages/server/modules/activitystream/tests/activity.spec.js index c4b9d9211..ab360e266 100644 --- a/packages/server/modules/activitystream/tests/activity.spec.js +++ b/packages/server/modules/activitystream/tests/activity.spec.js @@ -11,7 +11,7 @@ const { noErrors } = require('@/test/helpers') const { addOrUpdateStreamCollaborator } = require('@/modules/core/services/streams/streamAccessService') -const { Roles } = require('@/modules/core/helpers/mainConstants') +const { Roles, Scopes } = require('@speckle/shared') let sendRequest @@ -79,14 +79,14 @@ describe('Activity @activity', () => { ;({ sendRequest } = await initializeTestServer(server, app)) const normalScopesList = [ - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email ] // create users @@ -106,8 +106,8 @@ describe('Activity @activity', () => { (token) => (userCr.token = `Bearer ${token}`) ), createPersonalAccessToken(userX.id, 'no users:read test token', [ - 'streams:read', - 'streams:write' + Scopes.Streams.Read, + Scopes.Streams.Write ]).then((token) => (userX.token = `Bearer ${token}`)) // streams // createStream({ ...collaboratorTestStream, ownerId: userIz.id }).then( diff --git a/packages/server/modules/auth/graph/resolvers/apps.js b/packages/server/modules/auth/graph/resolvers/apps.js index ca72cc9b4..a8902700b 100644 --- a/packages/server/modules/auth/graph/resolvers/apps.js +++ b/packages/server/modules/auth/graph/resolvers/apps.js @@ -11,6 +11,7 @@ const { deleteApp, revokeExistingAppCredentialsForUser } = require('../../services/apps') +const { Roles } = require('@speckle/shared') module.exports = { Query: { @@ -56,10 +57,10 @@ module.exports = { async appUpdate(parent, args, context) { const app = await getApp({ id: args.app.id }) // only admins can update the default apps, generated by the server - if (!app.author && context.role !== 'server:admin') + if (!app.author && context.role !== Roles.Server.Admin) throw new ForbiddenError('You are not authorized to edit this app.') // only the author or an admin can update a 3rd party app - if (app.author.id !== context.userId && context.role !== 'server:admin') + if (app.author.id !== context.userId && context.role !== Roles.Server.Admin) throw new ForbiddenError('You are not authorized to edit this app.') await updateApp({ app: args.app }) @@ -69,9 +70,9 @@ module.exports = { async appDelete(parent, args, context) { const app = await getApp({ id: args.appId }) - if (!app.author && context.role !== 'server:admin') + if (!app.author && context.role !== Roles.Server.Admin) throw new ForbiddenError('You are not authorized to edit this app.') - if (app.author.id !== context.userId && context.role !== 'server:admin') + if (app.author.id !== context.userId && context.role !== Roles.Server.Admin) throw new ForbiddenError('You are not authorized to edit this app.') return (await deleteApp({ id: args.appId })) === 1 diff --git a/packages/server/modules/auth/rest/index.js b/packages/server/modules/auth/rest/index.js index a10b3cc4d..63c3f9057 100644 --- a/packages/server/modules/auth/rest/index.js +++ b/packages/server/modules/auth/rest/index.js @@ -14,6 +14,7 @@ const { revokeRefreshToken } = require(`@/modules/auth/services/apps`) const { validateScopes } = require(`@/modules/shared`) const { InvalidAccessCodeRequestError } = require('@/modules/auth/errors') const { ForbiddenError } = require('apollo-server-errors') +const { Scopes } = require('@speckle/shared') // TODO: Secure these endpoints! module.exports = (app) => { @@ -38,7 +39,7 @@ module.exports = (app) => { if (!valid) throw new InvalidAccessCodeRequestError('Invalid token') // 2. Validate token scopes - await validateScopes(scopes, 'tokens:write') + await validateScopes(scopes, Scopes.Tokens.Write) const ac = await createAuthorizationCode({ appId, userId, challenge }) return res.redirect(`${app.redirectUrl}?access_code=${ac}`) diff --git a/packages/server/modules/auth/scopes.js b/packages/server/modules/auth/scopes.js index b84b895df..0131b37de 100644 --- a/packages/server/modules/auth/scopes.js +++ b/packages/server/modules/auth/scopes.js @@ -1,13 +1,15 @@ 'use strict' +const { Scopes } = require('@speckle/shared') + module.exports = [ { - name: 'apps:read', + name: Scopes.Apps.Read, description: 'See what applications you have created or have authorized.', public: false }, { - name: 'apps:write', + name: Scopes.Apps.Write, description: 'Register applications on your behalf.', public: false } diff --git a/packages/server/modules/auth/strategies/github.js b/packages/server/modules/auth/strategies/github.js index 9f5b5efd8..7cffce873 100644 --- a/packages/server/modules/auth/strategies/github.js +++ b/packages/server/modules/auth/strategies/github.js @@ -30,6 +30,9 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => { clientID: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, callbackURL: new URL(strategy.callbackUrl, process.env.CANONICAL_URL).toString(), + // WARNING, the 'user:email' scope belongs to the GITHUB scopes + // DO NOT change it to our internal scope definitions !!! + // You have been warned. scope: ['profile', 'user:email'], passReqToCallback: true }, diff --git a/packages/server/modules/auth/tests/appsGrapql.spec.js b/packages/server/modules/auth/tests/appsGrapql.spec.js index b88de762d..d5aeb163f 100644 --- a/packages/server/modules/auth/tests/appsGrapql.spec.js +++ b/packages/server/modules/auth/tests/appsGrapql.spec.js @@ -11,6 +11,7 @@ const { createAuthorizationCode, createAppTokenFromAccessCode } = require('../services/apps') +const { Scopes } = require('@speckle/shared') let sendRequest let server @@ -33,9 +34,9 @@ describe('GraphQL @apps-api', () => { testUser.id = await createUser(testUser) testToken = `Bearer ${await createPersonalAccessToken(testUser.id, 'test token', [ - 'profile:read', - 'apps:read', - 'apps:write' + Scopes.Profile.Read, + Scopes.Apps.Read, + Scopes.Apps.Write ])}` testUser2 = { @@ -46,9 +47,9 @@ describe('GraphQL @apps-api', () => { testUser2.id = await createUser(testUser2) testToken2 = `Bearer ${await createPersonalAccessToken(testUser2.id, 'test token', [ - 'profile:read', - 'apps:read', - 'apps:write' + Scopes.Profile.Read, + Scopes.Apps.Read, + Scopes.Apps.Write ])}` }) @@ -67,7 +68,7 @@ describe('GraphQL @apps-api', () => { name: 'Test App', public: true, description: 'Test App Description', - scopes: ['streams:read'], + scopes: [Scopes.Streams.Read], redirectUrl: 'lol://what' } } @@ -88,7 +89,7 @@ describe('GraphQL @apps-api', () => { myApp: { name: 'Test App', description: 'Test App Description', - scopes: ['streams:read'], + scopes: [Scopes.Streams.Read], redirectUrl: 'lol://what' } } @@ -160,7 +161,7 @@ describe('GraphQL @apps-api', () => { id: testAppId, name: 'Updated Test App', description: 'Test App Description', - scopes: ['streams:read'], + scopes: [Scopes.Streams.Read], redirectUrl: 'lol://what' } } @@ -192,7 +193,7 @@ describe('GraphQL @apps-api', () => { name: 'Another Test App', public: false, description: 'Test App Description', - scopes: ['streams:read'], + scopes: [Scopes.Streams.Read], redirectUrl: 'lol://what' } } @@ -203,7 +204,7 @@ describe('GraphQL @apps-api', () => { name: 'The n-th Test App', public: false, description: 'Test App Description', - scopes: ['streams:read'], + scopes: [Scopes.Streams.Read], redirectUrl: 'lol://what' } } diff --git a/packages/server/modules/comments/graph/resolvers/comments.js b/packages/server/modules/comments/graph/resolvers/comments.js index c9b57174b..739642099 100644 --- a/packages/server/modules/comments/graph/resolvers/comments.js +++ b/packages/server/modules/comments/graph/resolvers/comments.js @@ -1,4 +1,4 @@ -const { pubsub } = require('@/modules/shared') +const { pubsub } = require('@/modules/shared/utils/subscriptions') const { ForbiddenError: ApolloForbiddenError } = require('apollo-server-express') const { ForbiddenError } = require('@/modules/shared/errors') const { getStream } = require('@/modules/core/services/streams') diff --git a/packages/server/modules/comments/services/index.js b/packages/server/modules/comments/services/index.js index 1fc117ab7..93231162b 100644 --- a/packages/server/modules/comments/services/index.js +++ b/packages/server/modules/comments/services/index.js @@ -13,6 +13,7 @@ const { markCommentViewed } = require('@/modules/comments/repositories/comments') const { clamp } = require('lodash') +const { Roles } = require('@speckle/shared') const Comments = () => knex('comments') const CommentLinks = () => knex('comment_links') @@ -221,7 +222,7 @@ module.exports = { .first() if (comment.authorId !== userId) { - if (!aclEntry || aclEntry.role !== 'stream:owner') + if (!aclEntry || aclEntry.role !== Roles.Stream.Owner) throw new ForbiddenError("You don't have permission to archive the comment") } diff --git a/packages/server/modules/core/graph/directives/hasRole.js b/packages/server/modules/core/graph/directives/hasRole.js index 8ca3292e8..eb250648b 100644 --- a/packages/server/modules/core/graph/directives/hasRole.js +++ b/packages/server/modules/core/graph/directives/hasRole.js @@ -1,11 +1,12 @@ const { defaultFieldResolver } = require('graphql') -const { validateServerRole, authorizeResolver } = require('@/modules/shared') +const { authorizeResolver } = require('@/modules/shared') const { ForbiddenError } = require('@/modules/shared/errors') const { mapSchema, getDirective, MapperKind } = require('@graphql-tools/utils') const { mapStreamRoleToValue, mapServerRoleToValue } = require('@/modules/core/helpers/graphTypes') +const { throwForNotHavingServerRole } = require('@/modules/shared/authz') module.exports = { /** @@ -19,6 +20,7 @@ module.exports = { enum ServerRole { SERVER_USER SERVER_ADMIN + SERVER_GUEST SERVER_ARCHIVED_USER } @@ -37,42 +39,10 @@ module.exports = { const { resolve = defaultFieldResolver } = fieldConfig fieldConfig.resolve = async function (...args) { const context = args[2] - await validateServerRole(context, mapServerRoleToValue(requiredRole)) - - return await resolve.apply(this, args) - } - - return fieldConfig - } - }) - } - }, - - /** - * Ensure that the user has the specified SERVER role (e.g. server user, admin etc.) - * @deprecated Use `hasServerRole` instead, as it relies on proper GQL enums - * @type {import('@/modules/core/graph/helpers/directiveHelper').GraphqlDirectiveBuilder} - */ - hasRole: () => { - const directiveName = 'hasRole' - return { - typeDefs: ` - """ - Ensure that the user has the specified SERVER role (e.g. server user, admin etc.) - """ - directive @${directiveName}(role: String!) on FIELD_DEFINITION - `, - schemaTransformer: (schema) => - mapSchema(schema, { - [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const directive = getDirective(schema, fieldConfig, directiveName)?.[0] - if (!directive) return undefined - - const { role: requiredRole } = directive - const { resolve = defaultFieldResolver } = fieldConfig - fieldConfig.resolve = async function (...args) { - const context = args[2] - await validateServerRole(context, requiredRole) + await throwForNotHavingServerRole( + context, + mapServerRoleToValue(requiredRole) + ) return await resolve.apply(this, args) } diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index db9dbe95e..f93a0a6a9 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -1883,6 +1883,7 @@ export type ServerInfo = { canonicalUrl?: Maybe; company?: Maybe; description?: Maybe; + guestModeEnabled: Scalars['Boolean']; inviteOnly?: Maybe; name: Scalars['String']; roles: Array>; @@ -1895,6 +1896,7 @@ export type ServerInfoUpdateInput = { adminContact?: InputMaybe; company?: InputMaybe; description?: InputMaybe; + guestModeEnabled?: InputMaybe; inviteOnly?: InputMaybe; name: Scalars['String']; termsOfService?: InputMaybe; @@ -1915,6 +1917,7 @@ export type ServerInviteCreateInput = { export enum ServerRole { ServerAdmin = 'SERVER_ADMIN', ServerArchivedUser = 'SERVER_ARCHIVED_USER', + ServerGuest = 'SERVER_GUEST', ServerUser = 'SERVER_USER' } @@ -3059,12 +3062,6 @@ export type ResolversParentTypes = { WebhookUpdateInput: WebhookUpdateInput; }; -export type HasRoleDirectiveArgs = { - role: Scalars['String']; -}; - -export type HasRoleDirectiveResolver = DirectiveResolverFn; - export type HasScopeDirectiveArgs = { scope: Scalars['String']; }; @@ -3722,6 +3719,7 @@ export type ServerInfoResolvers, ParentType, ContextType>; company?: Resolver, ParentType, ContextType>; description?: Resolver, ParentType, ContextType>; + guestModeEnabled?: Resolver; inviteOnly?: Resolver, ParentType, ContextType>; name?: Resolver; roles?: Resolver>, ParentType, ContextType>; @@ -4067,7 +4065,6 @@ export type Resolvers = { }; export type DirectiveResolvers = { - hasRole?: HasRoleDirectiveResolver; hasScope?: HasScopeDirectiveResolver; hasScopes?: HasScopesDirectiveResolver; hasServerRole?: HasServerRoleDirectiveResolver; diff --git a/packages/server/modules/core/graph/resolvers/branches.js b/packages/server/modules/core/graph/resolvers/branches.js index 4bf2b04b8..93e5e51dd 100644 --- a/packages/server/modules/core/graph/resolvers/branches.js +++ b/packages/server/modules/core/graph/resolvers/branches.js @@ -2,7 +2,11 @@ const { withFilter } = require('graphql-subscriptions') -const { authorizeResolver, pubsub, BranchPubsubEvents } = require('@/modules/shared') +const { + pubsub, + BranchSubscriptions: BranchPubsubEvents +} = require('@/modules/shared/utils/subscriptions') +const { authorizeResolver } = require('@/modules/shared') const { getBranchByNameAndStreamId, getBranchById } = require('../../services/branches') const { @@ -15,6 +19,7 @@ const { } = require('@/modules/core/services/branch/retrieval') const { getUserById } = require('../../services/users') +const { Roles } = require('@speckle/shared') // subscription events const BRANCH_CREATED = BranchPubsubEvents.BranchCreated @@ -62,7 +67,7 @@ module.exports = { await authorizeResolver( context.userId, args.branch.streamId, - 'stream:contributor' + Roles.Stream.Contributor ) const { id } = await createBranchAndNotify(args.branch, context.userId) @@ -74,7 +79,7 @@ module.exports = { await authorizeResolver( context.userId, args.branch.streamId, - 'stream:contributor' + Roles.Stream.Contributor ) const newBranch = await updateBranchAndNotify(args.branch, context.userId) @@ -85,7 +90,7 @@ module.exports = { await authorizeResolver( context.userId, args.branch.streamId, - 'stream:contributor' + Roles.Stream.Contributor ) const deleted = await deleteBranchAndNotify(args.branch, context.userId) @@ -97,7 +102,11 @@ module.exports = { subscribe: withFilter( () => pubsub.asyncIterator([BRANCH_CREATED]), async (payload, variables, context) => { - await authorizeResolver(context.userId, payload.streamId, 'stream:reviewer') + await authorizeResolver( + context.userId, + payload.streamId, + Roles.Stream.Reviewer + ) return payload.streamId === variables.streamId } @@ -108,7 +117,11 @@ module.exports = { subscribe: withFilter( () => pubsub.asyncIterator([BRANCH_UPDATED]), async (payload, variables, context) => { - await authorizeResolver(context.userId, payload.streamId, 'stream:reviewer') + await authorizeResolver( + context.userId, + payload.streamId, + Roles.Stream.Reviewer + ) const streamMatch = payload.streamId === variables.streamId if (streamMatch && variables.branchId) { @@ -124,7 +137,11 @@ module.exports = { subscribe: withFilter( () => pubsub.asyncIterator([BRANCH_DELETED]), async (payload, variables, context) => { - await authorizeResolver(context.userId, payload.streamId, 'stream:reviewer') + await authorizeResolver( + context.userId, + payload.streamId, + Roles.Stream.Reviewer + ) return payload.streamId === variables.streamId } diff --git a/packages/server/modules/core/graph/resolvers/commits.js b/packages/server/modules/core/graph/resolvers/commits.js index d31ad2bdb..5e6fc50bc 100644 --- a/packages/server/modules/core/graph/resolvers/commits.js +++ b/packages/server/modules/core/graph/resolvers/commits.js @@ -2,7 +2,11 @@ const { UserInputError, ApolloError } = require('apollo-server-express') const { withFilter } = require('graphql-subscriptions') -const { authorizeResolver, pubsub, CommitPubsubEvents } = require('@/modules/shared') +const { + pubsub, + CommitSubscriptions: CommitPubsubEvents +} = require('@/modules/shared/utils/subscriptions') +const { authorizeResolver } = require('@/modules/shared') const { getCommitById, @@ -39,6 +43,7 @@ const { validateStreamAccess } = require('@/modules/core/services/streams/streamAccessService') const { StreamInvalidAccessError } = require('@/modules/core/errors/stream') +const { Roles } = require('@speckle/shared') // subscription events const COMMIT_CREATED = CommitPubsubEvents.CommitCreated @@ -167,7 +172,7 @@ module.exports = { await authorizeResolver( context.userId, args.commit.streamId, - 'stream:contributor' + Roles.Stream.Contributor ) const rateLimitResult = await getRateLimitResult( @@ -190,7 +195,7 @@ module.exports = { await authorizeResolver( context.userId, args.commit.streamId, - 'stream:contributor' + Roles.Stream.Contributor ) await updateCommitAndNotify(args.commit, context.userId) @@ -198,7 +203,11 @@ module.exports = { }, async commitReceive(parent, args, context) { - await authorizeResolver(context.userId, args.input.streamId, 'stream:reviewer') + await authorizeResolver( + context.userId, + args.input.streamId, + Roles.Stream.Reviewer + ) const commit = await getCommitById({ streamId: args.input.streamId, @@ -218,7 +227,7 @@ module.exports = { await authorizeResolver( context.userId, args.commit.streamId, - 'stream:contributor' + Roles.Stream.Contributor ) const deleted = await deleteCommitAndNotify( @@ -244,7 +253,11 @@ module.exports = { subscribe: withFilter( () => pubsub.asyncIterator([COMMIT_CREATED]), async (payload, variables, context) => { - await authorizeResolver(context.userId, payload.streamId, 'stream:reviewer') + await authorizeResolver( + context.userId, + payload.streamId, + Roles.Stream.Reviewer + ) return payload.streamId === variables.streamId } ) @@ -254,7 +267,11 @@ module.exports = { subscribe: withFilter( () => pubsub.asyncIterator([COMMIT_UPDATED]), async (payload, variables, context) => { - await authorizeResolver(context.userId, payload.streamId, 'stream:reviewer') + await authorizeResolver( + context.userId, + payload.streamId, + Roles.Stream.Reviewer + ) const streamMatch = payload.streamId === variables.streamId if (streamMatch && variables.commitId) { @@ -270,7 +287,11 @@ module.exports = { subscribe: withFilter( () => pubsub.asyncIterator([COMMIT_DELETED]), async (payload, variables, context) => { - await authorizeResolver(context.userId, payload.streamId, 'stream:reviewer') + await authorizeResolver( + context.userId, + payload.streamId, + Roles.Stream.Reviewer + ) return payload.streamId === variables.streamId } diff --git a/packages/server/modules/core/graph/resolvers/objects.js b/packages/server/modules/core/graph/resolvers/objects.js index 6528a5543..3019b02b4 100644 --- a/packages/server/modules/core/graph/resolvers/objects.js +++ b/packages/server/modules/core/graph/resolvers/objects.js @@ -1,9 +1,5 @@ 'use strict' -const { - validateServerRole, - validateScopes, - authorizeResolver -} = require('@/modules/shared') +const { validateScopes, authorizeResolver } = require('@/modules/shared') const { createObjects, @@ -11,6 +7,8 @@ const { getObjectChildren, getObjectChildrenQuery } = require('../../services/objects') +const { Roles, Scopes } = require('@speckle/shared') +const { throwForNotHavingServerRole } = require('@/modules/shared/authz') module.exports = { Stream: { @@ -59,12 +57,12 @@ module.exports = { }, Mutation: { async objectCreate(parent, args, context) { - await validateServerRole(context, 'server:user') - await validateScopes(context.scopes, 'streams:write') + await throwForNotHavingServerRole(context, Roles.Server.Guest) + await validateScopes(context.scopes, Scopes.Streams.Write) await authorizeResolver( context.userId, args.objectInput.streamId, - 'stream:contributor' + Roles.Stream.Contributor ) const ids = await createObjects( diff --git a/packages/server/modules/core/graph/resolvers/projects.ts b/packages/server/modules/core/graph/resolvers/projects.ts index 6a0b4d63a..2bffad5ff 100644 --- a/packages/server/modules/core/graph/resolvers/projects.ts +++ b/packages/server/modules/core/graph/resolvers/projects.ts @@ -30,7 +30,8 @@ import { createStreamInviteAndNotify, useStreamInviteAndNotify } from '@/modules/serverinvites/services/management' -import { authorizeResolver, validateScopes, validateServerRole } from '@/modules/shared' +import { authorizeResolver, validateScopes } from '@/modules/shared' +import { throwForNotHavingServerRole } from '@/modules/shared/authz' import { filteredSubscribe, ProjectSubscriptions, @@ -52,7 +53,7 @@ export = { await authorizeResolver(context.userId, args.id, Roles.Stream.Reviewer) if (!stream.isPublic) { - await validateServerRole(context, Roles.Server.User) + await throwForNotHavingServerRole(context, Roles.Server.Guest) validateScopes(context.scopes, Scopes.Streams.Read) } diff --git a/packages/server/modules/core/graph/resolvers/server.js b/packages/server/modules/core/graph/resolvers/server.js index ad745b555..abe6ff44d 100644 --- a/packages/server/modules/core/graph/resolvers/server.js +++ b/packages/server/modules/core/graph/resolvers/server.js @@ -1,11 +1,13 @@ 'use strict' -const { validateServerRole, validateScopes } = require('@/modules/shared') +const { validateScopes } = require('@/modules/shared') const { updateServerInfo, getServerInfo, getPublicScopes, getPublicRoles } = require('../../services/generic') +const { Roles, Scopes } = require('@speckle/shared') +const { throwForNotHavingServerRole } = require('@/modules/shared/authz') module.exports = { Query: { @@ -26,8 +28,8 @@ module.exports = { Mutation: { async serverInfoUpdate(parent, args, context) { - await validateServerRole(context, 'server:admin') - await validateScopes(context.scopes, 'server:setup') + await throwForNotHavingServerRole(context, Roles.Server.Admin) + await validateScopes(context.scopes, Scopes.Server.Setup) await updateServerInfo(args.info) return true diff --git a/packages/server/modules/core/graph/resolvers/streams.js b/packages/server/modules/core/graph/resolvers/streams.js index add5e9d7e..2ba50f281 100644 --- a/packages/server/modules/core/graph/resolvers/streams.js +++ b/packages/server/modules/core/graph/resolvers/streams.js @@ -14,12 +14,11 @@ const { } = require('@/modules/core/services/streams') const { - authorizeResolver, pubsub, - StreamPubsubEvents, - validateScopes, - validateServerRole -} = require(`@/modules/shared`) + StreamSubscriptions: StreamPubsubEvents +} = require(`@/modules/shared/utils/subscriptions`) + +const { authorizeResolver, validateScopes } = require(`@/modules/shared`) const { RateLimitError, RateLimitAction, @@ -48,8 +47,9 @@ const { updateStreamRoleAndNotify } = require('@/modules/core/services/streams/management') const { adminOverrideEnabled } = require('@/modules/shared/helpers/envHelper') -const { Roles } = require('@speckle/shared') +const { Roles, Scopes } = require('@speckle/shared') const { StreamNotFoundError } = require('@/modules/core/errors/stream') +const { throwForNotHavingServerRole } = require('@/modules/shared/authz') // subscription events const USER_STREAM_ADDED = StreamPubsubEvents.UserStreamAdded @@ -85,11 +85,11 @@ module.exports = { throw new StreamNotFoundError('Stream not found') } - await authorizeResolver(context.userId, args.id, 'stream:reviewer') + await authorizeResolver(context.userId, args.id, Roles.Stream.Reviewer) if (!stream.isPublic) { - await validateServerRole(context, 'server:user') - await validateScopes(context.scopes, 'streams:read') + await throwForNotHavingServerRole(context, Roles.Server.Guest) + await validateScopes(context.scopes, Scopes.Streams.Read) } return stream @@ -221,13 +221,13 @@ module.exports = { }, async streamUpdate(parent, args, context) { - await authorizeResolver(context.userId, args.stream.id, 'stream:owner') + await authorizeResolver(context.userId, args.stream.id, Roles.Stream.Owner) await updateStreamAndNotify(args.stream, context.userId) return true }, async streamDelete(parent, args, context, info) { - await authorizeResolver(context.userId, args.id, 'stream:owner') + await authorizeResolver(context.userId, args.id, Roles.Stream.Owner) return await _deleteStream(parent, args, context, info) }, @@ -246,7 +246,7 @@ module.exports = { await authorizeResolver( context.userId, args.permissionParams.streamId, - 'stream:owner' + Roles.Stream.Owner ) const result = await updateStreamRoleAndNotify( @@ -260,7 +260,7 @@ module.exports = { await authorizeResolver( context.userId, args.permissionParams.streamId, - 'stream:owner' + Roles.Stream.Owner ) const result = await updateStreamRoleAndNotify( @@ -310,7 +310,7 @@ module.exports = { subscribe: withFilter( () => pubsub.asyncIterator([STREAM_UPDATED]), async (payload, variables, context) => { - await authorizeResolver(context.userId, payload.id, 'stream:reviewer') + await authorizeResolver(context.userId, payload.id, Roles.Stream.Reviewer) return payload.id === variables.streamId } ) @@ -320,7 +320,11 @@ module.exports = { subscribe: withFilter( () => pubsub.asyncIterator([STREAM_DELETED]), async (payload, variables, context) => { - await authorizeResolver(context.userId, payload.streamId, 'stream:reviewer') + await authorizeResolver( + context.userId, + payload.streamId, + Roles.Stream.Reviewer + ) return payload.streamId === variables.streamId } ) diff --git a/packages/server/modules/core/graph/resolvers/users.js b/packages/server/modules/core/graph/resolvers/users.js index 71d9084f8..87dd2e5d4 100644 --- a/packages/server/modules/core/graph/resolvers/users.js +++ b/packages/server/modules/core/graph/resolvers/users.js @@ -6,21 +6,21 @@ const { getUserRole, deleteUser, searchUsers, - makeUserAdmin, - unmakeUserAdmin, - archiveUser -} = require('../../services/users') + changeUserRole +} = require('@/modules/core/services/users') const { updateUserAndNotify } = require('@/modules/core/services/users/management') const { saveActivity } = require('@/modules/activitystream/services') const { ActionTypes } = require('@/modules/activitystream/helpers/types') -const { validateServerRole, validateScopes } = require(`@/modules/shared`) +const { validateScopes } = require(`@/modules/shared`) const zxcvbn = require('zxcvbn') const { getAdminUsersListCollection } = require('@/modules/core/services/users/adminUsersListService') -const { Roles, Scopes } = require('@/modules/core/helpers/mainConstants') +const { Roles, Scopes } = require('@speckle/shared') const { markOnboardingComplete } = require('@/modules/core/repositories/users') const { UsersMeta } = require('@/modules/core/dbSchema') +const { getServerInfo } = require('@/modules/core/services/generic') +const { throwForNotHavingServerRole } = require('@/modules/shared/authz') /** @type {import('@/modules/core/graph/generated/graphql').Resolvers} */ module.exports = { @@ -33,8 +33,8 @@ module.exports = { if (!activeUserId) return null // Only if authenticated - check for server roles & scopes - await validateServerRole(context, 'server:user') - await validateScopes(context.scopes, 'profile:read') + await throwForNotHavingServerRole(context, Roles.Server.Guest) + await validateScopes(context.scopes, Scopes.Profile.Read) return await getUser(activeUserId) }, @@ -47,10 +47,10 @@ module.exports = { // User wants info about himself and he's not authenticated - just return null if (!context.auth && !args.id) return null - await validateServerRole(context, 'server:user') + await throwForNotHavingServerRole(context, Roles.Server.Guest) - if (!args.id) await validateScopes(context.scopes, 'profile:read') - else await validateScopes(context.scopes, 'users:read') + if (!args.id) await validateScopes(context.scopes, Scopes.Profile.Read) + else await validateScopes(context.scopes, Scopes.Users.Read) if (!args.id && !context.userId) { throw new UserInputError('You must provide an user id.') @@ -64,9 +64,9 @@ module.exports = { }, async userSearch(parent, args, context) { - await validateServerRole(context, 'server:user') - await validateScopes(context.scopes, 'profile:read') - await validateScopes(context.scopes, 'users:read') + await throwForNotHavingServerRole(context, Roles.Server.Guest) + await validateScopes(context.scopes, Scopes.Profile.Read) + await validateScopes(context.scopes, Scopes.Users.Read) if (args.query.length < 3) throw new UserInputError('Search query must be at least 3 carachters.') @@ -97,7 +97,7 @@ module.exports = { // NOTE: we're redacting the field (returning null) rather than throwing a full error which would invalidate the request. if (context.userId === parent.id) { try { - await validateScopes(context.scopes, 'profile:email') + await validateScopes(context.scopes, Scopes.Profile.Email) return parent.email } catch (err) { return null @@ -106,7 +106,7 @@ module.exports = { try { // you should only have access to other users email if you have elevated privileges - await validateServerRole(context, Roles.Server.Admin) + await throwForNotHavingServerRole(context, Roles.Server.Admin) await validateScopes(context.scopes, Scopes.Users.Email) return parent.email } catch (err) { @@ -130,25 +130,24 @@ module.exports = { } }, Mutation: { - async userUpdate(parent, args, context) { - await validateServerRole(context, 'server:user') + async userUpdate(_parent, args, context) { + await throwForNotHavingServerRole(context, Roles.Server.Guest) await updateUserAndNotify(context.userId, args.user) return true }, - async userRoleChange(parent, args) { - const roleChangers = { - 'server:admin': makeUserAdmin, - 'server:user': unmakeUserAdmin, - 'server:archived-user': archiveUser - } - const roleChanger = roleChangers[args.userRoleInput.role] - await roleChanger({ userId: args.userRoleInput.id }) + async userRoleChange(_parent, args) { + const { guestModeEnabled } = await getServerInfo() + await changeUserRole({ + role: args.userRoleInput.role, + userId: args.userRoleInput.id, + guestModeEnabled + }) return true }, - async adminDeleteUser(parent, args, context) { - await validateServerRole(context, 'server:admin') + async adminDeleteUser(_parent, args, context) { + await throwForNotHavingServerRole(context, Roles.Server.Admin) const user = await getUserByEmail({ email: args.userConfirmation.email }) await deleteUser(user.id) return true @@ -164,8 +163,8 @@ module.exports = { // The below are not really needed anymore as we've added the hasRole and hasScope // directives in the graphql schema itself. // Since I am paranoid, I'll leave them here too. - await validateServerRole(context, 'server:user') - await validateScopes(context.scopes, 'profile:delete') + await throwForNotHavingServerRole(context, Roles.Server.Guest) + await validateScopes(context.scopes, Scopes.Profile.Delete) await deleteUser(context.userId, args.user) diff --git a/packages/server/modules/core/helpers/graphTypes.ts b/packages/server/modules/core/helpers/graphTypes.ts index ea03396ff..4e00d9d32 100644 --- a/packages/server/modules/core/helpers/graphTypes.ts +++ b/packages/server/modules/core/helpers/graphTypes.ts @@ -127,5 +127,7 @@ export function mapServerRoleToValue(graphqlServerRole: ServerRole): ServerRoles return Roles.Server.Admin case ServerRole.ServerArchivedUser: return Roles.Server.ArchivedUser + case ServerRole.ServerGuest: + return Roles.Server.Guest } } diff --git a/packages/server/modules/core/helpers/types.ts b/packages/server/modules/core/helpers/types.ts index 1364dd524..8f8be2c91 100644 --- a/packages/server/modules/core/helpers/types.ts +++ b/packages/server/modules/core/helpers/types.ts @@ -73,6 +73,7 @@ export type ServerConfigRecord = { canonicalUrl: string completed: boolean inviteOnly: boolean + guestModeEnabled: boolean } export type ServerInfo = ServerConfigRecord & { diff --git a/packages/server/modules/core/migrations/20230727150957_serverGuestMode.ts b/packages/server/modules/core/migrations/20230727150957_serverGuestMode.ts new file mode 100644 index 000000000..1ba1e7eda --- /dev/null +++ b/packages/server/modules/core/migrations/20230727150957_serverGuestMode.ts @@ -0,0 +1,16 @@ +import { Knex } from 'knex' + +const TABLE_NAME = 'server_config' +const COL_NAME = 'guestModeEnabled' + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TABLE_NAME, (table) => { + table.boolean(COL_NAME).defaultTo(false).notNullable() + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TABLE_NAME, (table) => { + table.dropColumn(COL_NAME) + }) +} diff --git a/packages/server/modules/core/repositories/streams.ts b/packages/server/modules/core/repositories/streams.ts index 02ea4545b..b2c22ed0c 100644 --- a/packages/server/modules/core/repositories/streams.ts +++ b/packages/server/modules/core/repositories/streams.ts @@ -930,7 +930,7 @@ export async function revokeStreamPermissions(params: { .select('*') .first() - if (aclEntry?.role === 'stream:owner') { + if (aclEntry?.role === Roles.Stream.Owner) { const [countObj] = await StreamAcl.knex() .where({ resourceId: streamId, diff --git a/packages/server/modules/core/rest/authUtils.js b/packages/server/modules/core/rest/authUtils.js index 186a6ff02..d9895c060 100644 --- a/packages/server/modules/core/rest/authUtils.js +++ b/packages/server/modules/core/rest/authUtils.js @@ -1,11 +1,9 @@ 'use strict' -const { - validateScopes, - validateServerRole, - authorizeResolver -} = require('@/modules/shared') +const { validateScopes, authorizeResolver } = require('@/modules/shared') const { getStream } = require('../services/streams') +const { Roles, Scopes } = require('@speckle/shared') +const { throwForNotHavingServerRole } = require('@/modules/shared/authz') module.exports = { async validatePermissionsReadStream(streamId, req) { @@ -13,7 +11,7 @@ module.exports = { if (stream?.isPublic) return { result: true, status: 200 } try { - await validateServerRole(req.context, 'server:user') + await throwForNotHavingServerRole(req.context, Roles.Server.Guest) } catch (err) { return { result: false, status: 401 } } @@ -26,13 +24,13 @@ module.exports = { if (!stream.isPublic) { try { - await validateScopes(req.context.scopes, 'streams:read') + await validateScopes(req.context.scopes, Scopes.Streams.Read) } catch (err) { return { result: false, status: 401 } } try { - await authorizeResolver(req.context.userId, streamId, 'stream:reviewer') + await authorizeResolver(req.context.userId, streamId, Roles.Stream.Reviewer) } catch (err) { return { result: false, status: 401 } } @@ -46,19 +44,19 @@ module.exports = { } try { - await validateServerRole(req.context, 'server:user') + await throwForNotHavingServerRole(req.context, Roles.Server.Guest) } catch (err) { return { result: false, status: 401 } } try { - await validateScopes(req.context.scopes, 'streams:write') + await validateScopes(req.context.scopes, Scopes.Streams.Write) } catch (err) { return { result: false, status: 401 } } try { - await authorizeResolver(req.context.userId, streamId, 'stream:contributor') + await authorizeResolver(req.context.userId, streamId, Roles.Stream.Contributor) } catch (err) { return { result: false, status: 401 } } diff --git a/packages/server/modules/core/roles.js b/packages/server/modules/core/roles.js index e73421ccf..d4e72a937 100644 --- a/packages/server/modules/core/roles.js +++ b/packages/server/modules/core/roles.js @@ -27,6 +27,18 @@ module.exports = [ weight: 100, public: false }, + // TODO: should this be dynamically pushed if guest role is enabled? + // feels risky, since feature can be toggled on and off, + // but user roles are not updated + // can leave the guest users in a broken state + { + name: Roles.Server.Guest, + description: 'Has limited access to the server.', + resourceTarget: 'server', + aclTableName: 'server_acl', + weight: 50, + public: false + }, { name: Roles.Server.ArchivedUser, description: 'No longer has access to the server.', diff --git a/packages/server/modules/core/services/generic.js b/packages/server/modules/core/services/generic.js index b240049cc..ef749fdce 100644 --- a/packages/server/modules/core/services/generic.js +++ b/packages/server/modules/core/services/generic.js @@ -38,7 +38,8 @@ module.exports = { description, adminContact, termsOfService, - inviteOnly + inviteOnly, + guestModeEnabled }) { const serverInfo = await Info().select('*').first() if (!serverInfo) @@ -49,6 +50,7 @@ module.exports = { adminContact, termsOfService, inviteOnly, + guestModeEnabled, completed: true }) else @@ -59,6 +61,7 @@ module.exports = { adminContact, termsOfService, inviteOnly, + guestModeEnabled, completed: true }) } diff --git a/packages/server/modules/core/services/streams/streamAccessService.js b/packages/server/modules/core/services/streams/streamAccessService.js index 1f633aeb6..994349a77 100644 --- a/packages/server/modules/core/services/streams/streamAccessService.js +++ b/packages/server/modules/core/services/streams/streamAccessService.js @@ -2,7 +2,7 @@ const { authorizeResolver } = require(`@/modules/shared`) const { Roles } = require('@/modules/core/helpers/mainConstants') const { LogicError } = require('@/modules/shared/errors') -const { ForbiddenError } = require('apollo-server-express') +const { ForbiddenError, UserInputError } = require('apollo-server-express') const { StreamInvalidAccessError } = require('@/modules/core/errors/stream') const { addStreamPermissionsAddedActivity, @@ -15,6 +15,8 @@ const { grantStreamPermissions } = require('@/modules/core/repositories/streams') +const { ServerAcl } = require('@/modules/core/dbSchema') + /** * Check if user is a stream collaborator * @param {string} userId @@ -129,6 +131,13 @@ async function addOrUpdateStreamCollaborator( await validateStreamAccess(addedById, streamId, Roles.Stream.Owner) + // make sure server guests cannot be stream owners + if (role === Roles.Stream.Owner) { + const userServerRole = await ServerAcl.knex().where({ userId }).first() + if (userServerRole.role === Roles.Server.Guest) + throw new UserInputError('Server guests cannot own streams') + } + const stream = await grantStreamPermissions({ streamId, userId, diff --git a/packages/server/modules/core/services/users.js b/packages/server/modules/core/services/users.js index 1ddc350f3..be9ea172f 100644 --- a/packages/server/modules/core/services/users.js +++ b/packages/server/modules/core/services/users.js @@ -26,17 +26,18 @@ const { UserInputError, PasswordTooShortError } = require('@/modules/core/errors/userinput') +const { Roles } = require('@speckle/shared') -const changeUserRole = async ({ userId, role }) => +const _changeUserRole = async ({ userId, role }) => await Acl().where({ userId }).update({ role }) const countAdminUsers = async () => { - const [{ count }] = await Acl().where({ role: 'server:admin' }).count() + const [{ count }] = await Acl().where({ role: Roles.Server.Admin }).count() return parseInt(count) } const _ensureAtleastOneAdminRemains = async (userId) => { if ((await countAdminUsers()) === 1) { - const currentAdmin = await Acl().where({ role: 'server:admin' }).first() + const currentAdmin = await Acl().where({ role: Roles.Server.Admin }).first() if (currentAdmin.userId === userId) { throw new UserInputError('Cannot remove the last admin role from the server') } @@ -93,7 +94,8 @@ module.exports = { const [newUser] = (await Users().insert(user, UsersSchema.cols)) || [] if (!newUser) throw new Error("Couldn't create user") - const userRole = (await countAdminUsers()) === 0 ? 'server:admin' : 'server:user' + const userRole = + (await countAdminUsers()) === 0 ? Roles.Server.Admin : Roles.Server.User await Acl().insert({ userId: newId, role: userRole }) @@ -182,7 +184,7 @@ module.exports = { .where((queryBuilder) => { queryBuilder.where({ email: searchQuery }) //match full email or partial name if (!emailOnly) queryBuilder.orWhere('name', 'ILIKE', `%${searchQuery}%`) - if (!archived) queryBuilder.andWhere('role', '!=', 'server:archived-user') + if (!archived) queryBuilder.andWhere('role', '!=', Roles.Server.ArchivedUser) }) if (cursor) query.andWhere('users.createdAt', '<', cursor) @@ -225,9 +227,9 @@ module.exports = { ( -- Get streams ids on which the user is owner SELECT "resourceId" FROM stream_acl - WHERE role = 'stream:owner' AND "userId" = ? + WHERE role = '${Roles.Stream.Owner}' AND "userId" = ? ) AS us ON acl."resourceId" = us."resourceId" - WHERE acl.role = 'stream:owner' + WHERE acl.role = '${Roles.Stream.Owner}' GROUP BY (acl."resourceId") ) AS soc WHERE cnt = 1 @@ -262,25 +264,18 @@ module.exports = { return users }, - async makeUserAdmin({ userId }) { - await changeUserRole({ userId, role: 'server:admin' }) - }, - - async unmakeUserAdmin({ userId }) { - // dont delete last admin role - await _ensureAtleastOneAdminRemains(userId) - await changeUserRole({ userId, role: 'server:user' }) - }, - - async archiveUser({ userId }) { - // dont change last admin to archived - await _ensureAtleastOneAdminRemains(userId) - await changeUserRole({ userId, role: 'server:archived-user' }) - }, - async countUsers(searchQuery = null) { const query = getUsersBaseQuery(searchQuery) const [userCount] = await query.count() return parseInt(userCount.count) + }, + + async changeUserRole({ userId, role, guestModeEnabled = false }) { + if (!Object.values(Roles.Server).includes(role)) + throw new UserInputError(`Invalid role specified: ${role}`) + if (!guestModeEnabled && role === Roles.Server.Guest) + throw new UserInputError('Guest role is not enabled') + if (role !== Roles.Server.Admin) await _ensureAtleastOneAdminRemains(userId) + await _changeUserRole({ userId, role }) } } diff --git a/packages/server/modules/core/tests/favoriteStreams.spec.js b/packages/server/modules/core/tests/favoriteStreams.spec.js index dba6f6612..abb3680a5 100644 --- a/packages/server/modules/core/tests/favoriteStreams.spec.js +++ b/packages/server/modules/core/tests/favoriteStreams.spec.js @@ -373,7 +373,7 @@ 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('must provide an auth token') + expect(result.errors.at(0).message).to.contain('Must provide an auth token') }) it("can't be retrieved", async () => { diff --git a/packages/server/modules/core/tests/generic.spec.js b/packages/server/modules/core/tests/generic.spec.js index 80364134f..838c4af1e 100644 --- a/packages/server/modules/core/tests/generic.spec.js +++ b/packages/server/modules/core/tests/generic.spec.js @@ -13,13 +13,11 @@ const { beforeEachContext } = require('@/test/hooks') const { createStream } = require('@/modules/core/services/streams') const { createUser } = require('@/modules/core/services/users') -const { - validateServerRole, - validateScopes, - authorizeResolver -} = require('@/modules/shared') +const { validateScopes, authorizeResolver } = require('@/modules/shared') const { buildContext } = require('@/modules/shared/middleware') const { ForbiddenError } = require('apollo-server-express') +const { Roles, Scopes } = require('@speckle/shared') +const { throwForNotHavingServerRole } = require('@/modules/shared/authz') describe('Generic AuthN & AuthZ controller tests', () => { before(async () => { @@ -60,7 +58,10 @@ describe('Generic AuthN & AuthZ controller tests', () => { ) it('Should validate server role', async () => { - await validateServerRole({ auth: true, role: 'server:user' }, 'server:admin') + await throwForNotHavingServerRole( + { auth: true, role: Roles.Server.User }, + Roles.Server.Admin + ) .then(() => { throw new Error('This should have been rejected') }) @@ -68,21 +69,28 @@ describe('Generic AuthN & AuthZ controller tests', () => { expect('You do not have the required server role').to.equal(err.message) ) - await validateServerRole({ auth: true, role: 'HACZOR' }, '133TCR3w') + await throwForNotHavingServerRole({ auth: true, role: 'HACZOR' }, '133TCR3w') .then(() => { throw new Error('This should have been rejected') }) - .catch((err) => expect('Invalid server role specified').to.equal(err.message)) + .catch((err) => + expect('Invalid role requirement specified').to.equal(err.message) + ) - await validateServerRole({ auth: true, role: 'server:admin' }, '133TCR3w') + await throwForNotHavingServerRole( + { auth: true, role: Roles.Server.Admin }, + '133TCR3w' + ) .then(() => { throw new Error('This should have been rejected') }) - .catch((err) => expect('Invalid server role specified').to.equal(err.message)) + .catch((err) => + expect('Invalid role requirement specified').to.equal(err.message) + ) - const test = await validateServerRole( - { auth: true, role: 'server:admin' }, - 'server:user' + const test = await throwForNotHavingServerRole( + { auth: true, role: Roles.Server.Admin }, + Roles.Server.User ) expect(test).to.equal(true) }) @@ -95,7 +103,7 @@ describe('Generic AuthN & AuthZ controller tests', () => { .catch((err) => expect('Unknown role: bar').to.equal(err.message)) // this caught me out, but streams:read is not a valid role for now - await authorizeResolver('foo', 'bar', 'streams:read') + await authorizeResolver('foo', 'bar', Scopes.Streams.Read) .then(() => { throw new Error('This should have been rejected') }) @@ -148,9 +156,9 @@ describe('Generic AuthN & AuthZ controller tests', () => { const role = await authorizeResolver( serverOwner.id, myStream.id, - 'stream:contributor' + Roles.Stream.Contributor ) - expect(role).to.equal('stream:owner') + expect(role).to.equal(Roles.Stream.Owner) }) it('should get the passed in role for server:admins if override enabled', async () => { @@ -159,13 +167,17 @@ describe('Generic AuthN & AuthZ controller tests', () => { const role = await authorizeResolver( serverOwner.id, myStream.id, - 'stream:contributor' + Roles.Stream.Contributor ) - expect(role).to.equal('stream:contributor') + expect(role).to.equal(Roles.Stream.Contributor) }) it('should not allow server:admins to be anything if adminOverride is disabled', async () => { try { - await authorizeResolver(serverOwner.id, notMyStream.id, 'stream:contributor') + await authorizeResolver( + serverOwner.id, + notMyStream.id, + Roles.Stream.Contributor + ) throw 'This should have thrown' } catch (e) { expect(e instanceof ForbiddenError) @@ -179,14 +191,14 @@ describe('Generic AuthN & AuthZ controller tests', () => { const role = await authorizeResolver( serverOwner.id, notMyStream.id, - 'stream:contributor' + Roles.Stream.Contributor ) - expect(role).to.equal('stream:contributor') + expect(role).to.equal(Roles.Stream.Contributor) }) it('should not allow server:users to be anything if adminOverride is disabled', async () => { try { - await authorizeResolver(otherGuy.id, myStream.id, 'stream:contributor') + await authorizeResolver(otherGuy.id, myStream.id, Roles.Stream.Contributor) throw 'This should have thrown' } catch (e) { expect(e instanceof ForbiddenError) @@ -197,7 +209,7 @@ describe('Generic AuthN & AuthZ controller tests', () => { envHelperMock.enable() envHelperMock.mockFunction('adminOverrideEnabled', () => true) try { - await authorizeResolver(otherGuy.id, myStream.id, 'stream:contributor') + await authorizeResolver(otherGuy.id, myStream.id, Roles.Stream.Contributor) throw 'This should have thrown' } catch (e) { expect(e instanceof ForbiddenError) diff --git a/packages/server/modules/core/tests/graph.spec.js b/packages/server/modules/core/tests/graph.spec.js index 1d65f6e45..0df39f1f3 100644 --- a/packages/server/modules/core/tests/graph.spec.js +++ b/packages/server/modules/core/tests/graph.spec.js @@ -5,13 +5,17 @@ const request = require('supertest') const { beforeEachContext, initializeTestServer } = require(`@/test/hooks`) const { generateManyObjects } = require(`@/test/helpers`) -const { createUser, getUsers, archiveUser } = require('../services/users') +const { + createUser, + getUsers, + changeUserRole +} = require('@/modules/core/services/users') const { createPersonalAccessToken } = require('../services/tokens') const { addOrUpdateStreamCollaborator, removeStreamCollaborator } = require('@/modules/core/services/streams/streamAccessService') -const { Roles } = require('@/modules/core/helpers/mainConstants') +const { Roles, Scopes } = require('@speckle/shared') let app let server @@ -44,15 +48,15 @@ describe('GraphQL API Core @core-api', () => { userA.id, 'test token user A', [ - 'server:setup', - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email' + Scopes.Server.Setup, + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email ] )}` userB.id = await createUser(userB) @@ -60,14 +64,14 @@ describe('GraphQL API Core @core-api', () => { userB.id, 'test token user B', [ - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email ] )}` userC.id = await createUser(userC) @@ -75,14 +79,14 @@ describe('GraphQL API Core @core-api', () => { userC.id, 'test token user B', [ - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email ] )}` @@ -243,14 +247,14 @@ describe('GraphQL API Core @core-api', () => { userDelete.id, 'fail token user del', [ - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email ] )}` @@ -271,15 +275,15 @@ describe('GraphQL API Core @core-api', () => { userDelete.id, 'test token user del', [ - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email', - 'profile:delete' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email, + Scopes.Profile.Delete ] )}` @@ -303,30 +307,30 @@ describe('GraphQL API Core @core-api', () => { let queriedUserB = await sendRequest(userA.token, { query: ` { otherUser(id:"${userB.id}") { id name role } }` }) - expect(queriedUserB.body.data.otherUser.role).to.equal('server:user') - let query = `mutation { userRoleChange(userRoleInput: {id: "${userB.id}", role: "server:admin"})}` + expect(queriedUserB.body.data.otherUser.role).to.equal(Roles.Server.User) + let query = `mutation { userRoleChange(userRoleInput: {id: "${userB.id}", role: "${Roles.Server.Admin}"})}` await sendRequest(userA.token, { query }) queriedUserB = await sendRequest(userA.token, { query: ` { otherUser(id:"${userB.id}") { id name role } }` }) - expect(queriedUserB.body.data.otherUser.role).to.equal('server:admin') + expect(queriedUserB.body.data.otherUser.role).to.equal(Roles.Server.Admin) expect(queriedUserB.body.data) - query = `mutation { userRoleChange(userRoleInput: {id: "${userB.id}", role: "server:user"})}` + query = `mutation { userRoleChange(userRoleInput: {id: "${userB.id}", role: "${Roles.Server.User}"})}` await sendRequest(userA.token, { query }) queriedUserB = await sendRequest(userA.token, { query: ` { otherUser(id:"${userB.id}") { id name role } }` }) - expect(queriedUserB.body.data.otherUser.role).to.equal('server:user') + expect(queriedUserB.body.data.otherUser.role).to.equal(Roles.Server.User) }) it('Only admins can change user role', async () => { - const query = `mutation { userRoleChange(userRoleInput: {id: "${userB.id}", role: "server:admin"})}` + const query = `mutation { userRoleChange(userRoleInput: {id: "${userB.id}", role: "${Roles.Server.Admin}"})}` const res = await sendRequest(userB.token, { query }) const queriedUserB = await sendRequest(userA.token, { query: ` { otherUser(id:"${userB.id}") { id name role } }` }) expect(res.body.errors).to.exist - expect(queriedUserB.body.data.otherUser.role).to.equal('server:user') + expect(queriedUserB.body.data.otherUser.role).to.equal(Roles.Server.User) }) }) @@ -1049,7 +1053,7 @@ describe('GraphQL API Core @core-api', () => { expect(res.body.data).to.have.property('user') expect(res.body.data.user.name).to.equal('MiticÄ') expect(res.body.data.user.email).to.equal('d.1@speckle.systems') - expect(res.body.data.user.role).to.equal('server:admin') + expect(res.body.data.user.role).to.equal(Roles.Server.Admin) }) it('Should retrieve my streams', async () => { @@ -1271,8 +1275,8 @@ describe('GraphQL API Core @core-api', () => { expect(stream.name).to.equal('TS1 (u A) Private UPDATED') expect(stream.collaborators).to.have.lengthOf(2) - expect(stream.collaborators[0].role).to.equal('stream:contributor') - expect(stream.collaborators[1].role).to.equal('stream:owner') + expect(stream.collaborators[0].role).to.equal(Roles.Stream.Contributor) + expect(stream.collaborators[1].role).to.equal(Roles.Stream.Owner) }) it('Should retrieve a public stream even if not authenticated', async () => { @@ -1688,20 +1692,20 @@ describe('GraphQL API Core @core-api', () => { archivedUser.id, 'this will be archived', [ - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email', - 'apps:read', - 'apps:write', - 'users:invite' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email, + Scopes.Apps.Read, + Scopes.Apps.Write, + Scopes.Users.Invite ] )}` - await archiveUser({ userId: archivedUser.id }) + await changeUserRole({ userId: archivedUser.id, role: Roles.Server.ArchivedUser }) }) it('Should be able to read public streams', async () => { @@ -1730,7 +1734,7 @@ describe('GraphQL API Core @core-api', () => { query, variables: { tokenInput: { - scopes: ['streams:read'], + scopes: [Scopes.Streams.Read], name: 'thisWillNotBeCreated', lifespan: 1000000 } @@ -1835,7 +1839,7 @@ describe('GraphQL API Core @core-api', () => { name: 'Test App', public: true, description: 'Test App Description', - scopes: ['streams:read'], + scopes: [Scopes.Streams.Read], redirectUrl: 'lol://what' } } diff --git a/packages/server/modules/core/tests/graphSubs.spec.js b/packages/server/modules/core/tests/graphSubs.spec.js index 57cfa61ce..ee18e0d24 100644 --- a/packages/server/modules/core/tests/graphSubs.spec.js +++ b/packages/server/modules/core/tests/graphSubs.spec.js @@ -17,7 +17,7 @@ const { packageRoot } = require('@/bootstrap') const { addOrUpdateStreamCollaborator } = require('@/modules/core/services/streams/streamAccessService') -const { Roles } = require('@/modules/core/helpers/mainConstants') +const { Roles, Scopes } = require('@speckle/shared') const { getFreeServerPort } = require('@/test/serverHelper') let addr @@ -100,14 +100,14 @@ describe('GraphQL API Subscriptions @gql-subscriptions', () => { userA.id = await createUser(userA) const token = await createPersonalAccessToken(userA.id, 'test token user A', [ - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email ]) userA.token = `Bearer ${token}` @@ -116,14 +116,14 @@ describe('GraphQL API Subscriptions @gql-subscriptions', () => { userB.id, 'test token user B', [ - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email ] )}` @@ -131,7 +131,7 @@ describe('GraphQL API Subscriptions @gql-subscriptions', () => { userC.token = `Bearer ${await createPersonalAccessToken( userC.id, 'test token user B', - ['streams:read', 'streams:write', 'users:read', 'users:email'] + [Scopes.Streams.Read, Scopes.Streams.Write, Scopes.Users.Read, Scopes.Users.Email] )}` }) diff --git a/packages/server/modules/core/tests/rest.spec.js b/packages/server/modules/core/tests/rest.spec.js index d0f17769c..50624dd13 100644 --- a/packages/server/modules/core/tests/rest.spec.js +++ b/packages/server/modules/core/tests/rest.spec.js @@ -11,6 +11,7 @@ const { createManyObjects } = require('@/test/helpers') const { createUser } = require('../services/users') const { createPersonalAccessToken } = require('../services/tokens') const { createStream } = require('../services/streams') +const { Scopes } = require('@speckle/shared') describe('Upload/Download Routes @api-rest', () => { const userA = { @@ -40,14 +41,14 @@ describe('Upload/Download Routes @api-rest', () => { userA.id, 'test token user A', [ - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email ] )}` @@ -56,14 +57,14 @@ describe('Upload/Download Routes @api-rest', () => { userB.id, 'test token user B', [ - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email ] )}` diff --git a/packages/server/modules/core/tests/streams.spec.ts b/packages/server/modules/core/tests/streams.spec.ts index fdc202cc5..2e949f800 100644 --- a/packages/server/modules/core/tests/streams.spec.ts +++ b/packages/server/modules/core/tests/streams.spec.ts @@ -5,9 +5,9 @@ import { updateStream, deleteStream, getStreamUsers, - grantPermissionsStream, - revokePermissionsStream + grantPermissionsStream } from '../services/streams' + import { createBranch, getBranchByNameAndStreamId, @@ -37,7 +37,10 @@ import { createTestStream, createTestStreams } from '@/test/speckle-helpers/streamHelper' -import { StreamWithOptionalRole } from '@/modules/core/repositories/streams' +import { + StreamWithOptionalRole, + revokeStreamPermissions +} from '@/modules/core/repositories/streams' import { has, times } from 'lodash' import { Streams } from '@/modules/core/dbSchema' import { ApolloServer } from 'apollo-server-express' @@ -49,6 +52,7 @@ import { GetUserStreamsQuery } from '@/test/graphql/generated/graphql' import { Get } from 'type-fest' +import { changeUserRole } from '@/modules/core/services/users' describe('Streams @core-streams', () => { const userOne: BasicTestUser = { @@ -174,7 +178,7 @@ describe('Streams @core-streams', () => { }) it('Should revoke permissions on stream', async () => { - await revokePermissionsStream({ streamId: testStream.id, userId: userTwo.id }) + await revokeStreamPermissions({ streamId: testStream.id, userId: userTwo.id }) const streamWithRole = await getStream({ streamId: testStream.id, userId: userTwo.id @@ -183,7 +187,7 @@ describe('Streams @core-streams', () => { }) it('Should not revoke owner permissions', async () => { - await revokePermissionsStream({ streamId: testStream.id, userId: userOne.id }) + await revokeStreamPermissions({ streamId: testStream.id, userId: userOne.id }) .then(() => { throw new Error('This should have thrown') }) @@ -215,6 +219,35 @@ describe('Streams @core-streams', () => { const userIsCollaborator = await isStreamCollaborator(userTwo.id, streamId) expect(userIsCollaborator).to.not.be.ok }) + it('Server guests cannot be stream owners', async () => { + const guestGuy: BasicTestUser = { + name: 'Some we do not fully trust', + email: 'shady@contractor.company', + password: 'foobar123', + id: '' + } + + await createTestUsers([guestGuy]) + + await changeUserRole({ + userId: guestGuy.id, + role: Roles.Server.Guest, + guestModeEnabled: true + }) + + await addOrUpdateStreamCollaborator( + testStream.id, + guestGuy.id, + Roles.Stream.Owner, + userOne.id + ) + .then(() => { + throw new Error('This should have thrown') + }) + .catch((err) => { + expect(err.message).to.include('Server guests cannot own streams') + }) + }) }) describe('`UpdatedAt` prop update', () => { @@ -253,7 +286,7 @@ describe('Streams @core-streams', () => { await grantPermissionsStream({ streamId: updatableStream.id, userId: userTwo.id, - role: 'stream:contributor' + role: Roles.Stream.Contributor }) // await sleep(100) @@ -262,7 +295,7 @@ describe('Streams @core-streams', () => { expect(su!.updatedAt).to.not.equal(lastUpdatedAt) lastUpdatedAt = su!.updatedAt - await revokePermissionsStream({ + await revokeStreamPermissions({ streamId: updatableStream.id, userId: userTwo.id }) diff --git a/packages/server/modules/core/tests/users.spec.js b/packages/server/modules/core/tests/users.spec.js index 915f0a94f..a984bd748 100644 --- a/packages/server/modules/core/tests/users.spec.js +++ b/packages/server/modules/core/tests/users.spec.js @@ -7,7 +7,7 @@ const assert = require('assert') const knex = require('@/db/knex') const { - archiveUser, + changeUserRole, createUser, findOrCreateUser, getUser, @@ -41,6 +41,7 @@ const { const { createObject } = require('../services/objects') const { beforeEachContext } = require('@/test/hooks') +const { Scopes, Roles } = require('@speckle/shared') describe('Actors & Tokens @user-services', () => { const myTestActor = { @@ -193,7 +194,7 @@ describe('Actors & Tokens @user-services', () => { await grantPermissionsStream({ streamId: multiOwnerStream.id, userId: myTestActor.id, - role: 'stream:owner' + role: Roles.Stream.Owner }) // create a branch for ballmer on the multiowner stream @@ -292,7 +293,7 @@ describe('Actors & Tokens @user-services', () => { password: 'nanananananaaaa' }) - await archiveUser({ userId: toBeArchivedId }) + await changeUserRole({ userId: toBeArchivedId, role: Roles.Server.ArchivedUser }) let { users } = await searchUsers('Library', 20, null) expect(users).to.have.lengthOf(1) @@ -368,24 +369,24 @@ describe('Actors & Tokens @user-services', () => { before(async () => { pregeneratedToken = await createPersonalAccessToken(myTestActor.id, 'Whabadub', [ - 'streams:read', - 'streams:write', - 'profile:read', - 'users:email' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Profile.Read, + Scopes.Users.Email ]) revokedToken = await createPersonalAccessToken(myTestActor.id, 'Mr. Revoked', [ - 'streams:read' + Scopes.Streams.Read ]) expireSoonToken = await createPersonalAccessToken( myTestActor.id, 'Mayfly', - ['streams:read'], + [Scopes.Streams.Read], 1 ) // 1ms lifespan }) it('Should create a personal api token', async () => { - const scopes = ['streams:write', 'profile:read'] + const scopes = [Scopes.Streams.Write, Scopes.Profile.Read] const name = 'My Test Token' myFirstToken = await createPersonalAccessToken(myTestActor.id, name, scopes) diff --git a/packages/server/modules/core/tests/usersAdmin.spec.js b/packages/server/modules/core/tests/usersAdmin.spec.js index dcdb67ed9..de9162192 100644 --- a/packages/server/modules/core/tests/usersAdmin.spec.js +++ b/packages/server/modules/core/tests/usersAdmin.spec.js @@ -6,11 +6,12 @@ const { getUsers, countUsers, deleteUser, - getUserRole, - unmakeUserAdmin, - makeUserAdmin -} = require('../services/users') + changeUserRole, + getUserRole +} = require('@/modules/core/services/users') const { beforeEachContext } = require('@/test/hooks') +const { Roles } = require('@speckle/shared') +const cryptoRandomString = require('crypto-random-string') describe('User admin @user-services', () => { const myTestActor = { @@ -33,7 +34,7 @@ describe('User admin @user-services', () => { const firstUser = users[0] const userRole = await getUserRole(firstUser.id) - expect(userRole).to.equal('server:admin') + expect(userRole).to.equal(Roles.Server.Admin) }) it('Count user knows how to count', async () => { @@ -52,14 +53,6 @@ describe('User admin @user-services', () => { }) it('Get users query limit is sanitized to upper limit', async () => { - const createNewDroid = (number) => { - return { - name: `${number}`, - email: `${number}@droidarmy.com`, - password: 'sn3aky-1337-b1m' - } - } - const userInputs = Array(250) .fill() .map((v, i) => createNewDroid(i)) @@ -89,27 +82,66 @@ describe('User admin @user-services', () => { expect(await countUsers('droid')).to.equal(250) }) - it('Change user role modifies role', async () => { - const [user] = await getUsers(1, 10) + describe('changeUserRole', () => { + it('throws for invalid role value', async () => { + const role = 'shadow:lurker' + try { + await changeUserRole({ userId: myTestActor.id, role }) + assert.fail('This should have failed') + } catch (err) { + expect(err.message).to.equal(`Invalid role specified: ${role}`) + } + }) + it('throws if guest role not enabled, but trying to change user role to guest', async () => { + const role = Roles.Server.Guest + try { + await changeUserRole({ userId: myTestActor.id, role }) + assert.fail('This should have failed') + } catch (err) { + expect(err.message).to.equal('Guest role is not enabled') + } + }) + it('modifies role', async () => { + const userId = await createUser( + createNewDroid(cryptoRandomString({ length: 13 })) + ) - const oldRole = await getUserRole(user.id) - expect(oldRole).to.equal('server:user') + const oldRole = await getUserRole(userId) + expect(oldRole).to.equal(Roles.Server.User) - await makeUserAdmin({ userId: user.id }) - let newRole = await getUserRole(user.id) - expect(newRole).to.equal('server:admin') + await changeUserRole({ userId, role: Roles.Server.Admin }) + let newRole = await getUserRole(userId) + expect(newRole).to.equal(Roles.Server.Admin) - await unmakeUserAdmin({ userId: user.id }) - newRole = await getUserRole(user.id) - expect(newRole).to.equal('server:user') - }) + await changeUserRole({ userId, role: Roles.Server.User }) + newRole = await getUserRole(userId) + expect(newRole).to.equal(Roles.Server.User) - it('Ensure at least one admin remains in the server', async () => { - try { - await unmakeUserAdmin({ userId: myTestActor.id, role: 'server:admin' }) - assert.fail('This should have failed') - } catch (err) { - expect(err.message).to.equal('Cannot remove the last admin role from the server') - } + await changeUserRole({ + userId, + role: Roles.Server.Guest, + guestModeEnabled: true + }) + newRole = await getUserRole(userId) + expect(newRole).to.equal(Roles.Server.Guest) + }) + it('Ensures at least one admin remains in the server', async () => { + try { + await changeUserRole({ userId: myTestActor.id, role: Roles.Server.User }) + assert.fail('This should have failed') + } catch (err) { + expect(err.message).to.equal( + 'Cannot remove the last admin role from the server' + ) + } + }) }) }) + +const createNewDroid = (number) => { + return { + name: `${number}`, + email: `${number}@droidarmy.com`, + password: 'sn3aky-1337-b1m' + } +} diff --git a/packages/server/modules/notifications/tests/activityDigest.spec.ts b/packages/server/modules/notifications/tests/activityDigest.spec.ts index ff5e4d20c..4c7cf2798 100644 --- a/packages/server/modules/notifications/tests/activityDigest.spec.ts +++ b/packages/server/modules/notifications/tests/activityDigest.spec.ts @@ -49,7 +49,8 @@ describe('Activity digest notifications @notifications', () => { canonicalUrl: 'this would be localhost:// or whatever', completed: false, inviteOnly: true, - version: 'testing 1 2 3' + version: 'testing 1 2 3', + guestModeEnabled: false } const topic: DigestTopic = { diff --git a/packages/server/modules/previews/index.js b/packages/server/modules/previews/index.js index c3945d52f..9ebba9b45 100644 --- a/packages/server/modules/previews/index.js +++ b/packages/server/modules/previews/index.js @@ -21,6 +21,7 @@ const { moduleLogger, logger } = require('@/logging/logging') const { listenForPreviewGenerationUpdates } = require('@/modules/previews/services/resultListener') +const { Scopes, Roles } = require('@speckle/shared') const httpErrorImage = (httpErrorCode) => require.resolve(`#/assets/previews/images/preview_${httpErrorCode}.png`) @@ -144,7 +145,7 @@ exports.init = (app) => { if (!stream.isPublic) { try { - await validateScopes(req.context.scopes, 'streams:read') + await validateScopes(req.context.scopes, Scopes.Streams.Read) } catch (err) { return { hasPermissions: false, httpErrorCode: 401 } } @@ -153,7 +154,7 @@ exports.init = (app) => { await authorizeResolver( req.context.userId, req.params.streamId, - 'stream:reviewer' + Roles.Stream.Reviewer ) } catch (err) { return { hasPermissions: false, httpErrorCode: 401 } diff --git a/packages/server/modules/shared/authz.ts b/packages/server/modules/shared/authz.ts index c7e8c74e4..89a5897d9 100644 --- a/packages/server/modules/shared/authz.ts +++ b/packages/server/modules/shared/authz.ts @@ -4,7 +4,7 @@ import { ServerRoles, StreamRoles } from '@/modules/core/helpers/mainConstants' -import { getRoles } from '@/modules/shared' +import { getRoles } from '@/modules/shared/roles' import { getStream } from '@/modules/core/services/streams' import { @@ -114,17 +114,13 @@ export function validateRole({ // role validation has nothing to do with auth... //this check doesn't belong here, move it out to the auth pipeline if (!context.auth) - return authFailed( - context, - new UnauthorizedError('Cannot validate role without auth') - ) + return authFailed(context, new UnauthorizedError('Must provide an auth token')) const contextRole = roleGetter(context) - if (!contextRole) - return authFailed( - context, - new ForbiddenError('You do not have the required role') - ) + const missingRoleMessage = `You do not have the required ${ + requiredRole.split(':')[0] + } role` + if (!contextRole) return authFailed(context, new ForbiddenError(missingRoleMessage)) const role = roles.find((r) => r.name === requiredRole) const myRole = roles.find((r) => r.name === contextRole) @@ -138,7 +134,7 @@ export function validateRole({ return authFailed(context, new ForbiddenError('Your role is not valid')) if (myRole.name === iddqd || myRole.weight >= role.weight) return authSuccess(context) - return authFailed(context, new ForbiddenError('You do not have the required role')) + return authFailed(context, new ForbiddenError(missingRoleMessage)) } } @@ -277,3 +273,16 @@ export const streamReadPermissions = [ ] if (adminOverrideEnabled()) streamReadPermissions.push(allowForServerAdmins) + +export const throwForNotHavingServerRole = async ( + context: AuthContext, + requiredRole: ServerRoles +) => { + const { authResult } = await validateServerRole({ requiredRole })({ + context, + authResult: { authorized: false } + }) + if (authHasFailed(authResult)) + throw authResult.error ?? new Error('Auth failed without an error') + return true +} diff --git a/packages/server/modules/shared/errors/index.ts b/packages/server/modules/shared/errors/index.ts index 82f0507e2..43e653319 100644 --- a/packages/server/modules/shared/errors/index.ts +++ b/packages/server/modules/shared/errors/index.ts @@ -1,7 +1,7 @@ import { BaseError } from '@/modules/shared/errors/base' export class ForbiddenError extends BaseError { - static code = 'FORBIDDEN_ERROR' + static code = 'FORBIDDEN' static defaultMessage = 'Access to the resource is forbidden' } diff --git a/packages/server/modules/shared/index.js b/packages/server/modules/shared/index.js index f0f027354..dfda2a8ba 100644 --- a/packages/server/modules/shared/index.js +++ b/packages/server/modules/shared/index.js @@ -11,39 +11,9 @@ const { Roles } = require('@speckle/shared') const { adminOverrideEnabled } = require('@/modules/shared/helpers/envHelper') const { ServerAcl: ServerAclSchema } = require('@/modules/core/dbSchema') +const { getRoles } = require('@/modules/shared/roles') const ServerAcl = () => ServerAclSchema.knex() -let roles - -const getRoles = async () => { - if (roles) return roles - roles = await knex('user_roles').select('*') - return roles -} - -/** - * Validates a server role against the req's context object. - * @param {import('@/modules/shared/helpers/typeHelper').GraphQLContext} context - * @param {string} requiredRole - */ -async function validateServerRole(context, requiredRole) { - const roles = await getRoles() - - if (!context.auth) throw new ForbiddenError('You must provide an auth token.') - - const role = roles.find((r) => r.name === requiredRole) - const myRole = roles.find((r) => r.name === context.role) - - if (!role) throw new ApolloError('Invalid server role specified') - if (!myRole) - throw new ForbiddenError('You do not have the required server role (null)') - - if (context.role === 'server:admin') return true - if (myRole.weight >= role.weight) return true - - throw new ForbiddenError('You do not have the required server role') -} - /** * Validates the scope against a list of scopes of the current session. * @param {string[]|undefined} scopes @@ -65,7 +35,7 @@ async function validateScopes(scopes, scope) { async function authorizeResolver(userId, resourceId, requiredRole) { userId = userId || null - if (!roles) roles = await knex('user_roles').select('*') + const roles = await getRoles() // TODO: Cache these results with a TTL of 1 mins or so, it's pointless to query the db every time we get a ping. @@ -129,7 +99,7 @@ async function registerOrUpdateRole(role) { module.exports = { registerOrUpdateScope, registerOrUpdateRole, - validateServerRole, + // validateServerRole, validateScopes, authorizeResolver, pubsub, diff --git a/packages/server/modules/shared/roles.js b/packages/server/modules/shared/roles.js new file mode 100644 index 000000000..4cf2f10f5 --- /dev/null +++ b/packages/server/modules/shared/roles.js @@ -0,0 +1,12 @@ +const knex = require(`@/db/knex`) +let roles + +const getRoles = async () => { + if (roles) return roles + roles = await knex('user_roles').select('*') + return roles +} + +module.exports = { + getRoles +} diff --git a/packages/server/modules/shared/test/authz.spec.js b/packages/server/modules/shared/test/authz.spec.js index 8b1d06e83..829ef0ed0 100644 --- a/packages/server/modules/shared/test/authz.spec.js +++ b/packages/server/modules/shared/test/authz.spec.js @@ -66,7 +66,7 @@ describe('AuthZ @shared', () => { describe('Role validation', () => { const rolesLookup = async () => [ { name: '1', weight: 1 }, - { name: '2', weight: 2 }, + { name: 'server:2', weight: 2 }, { name: '3', weight: 3 }, { name: 'goku', weight: 9001 }, { name: '42', weight: 42 } @@ -75,15 +75,18 @@ describe('AuthZ @shared', () => { const testData = [ { name: 'Having lower privileged role than required results auth failed', - requiredRole: '2', + requiredRole: 'server:2', context: { auth: true, role: '1' }, - expectedResult: authFailed(null, new SFE('You do not have the required role')) + expectedResult: authFailed( + null, + new SFE('You do not have the required server role') + ) }, { name: 'Not having auth fails role validation', - requiredRole: '2', + requiredRole: 'server:2', context: { auth: false }, - expectedResult: authFailed(null, new SUE('Cannot validate role without auth')) + expectedResult: authFailed(null, new SUE('Must provide an auth token')) }, { name: 'Requiring a junk role fails auth', @@ -93,7 +96,7 @@ describe('AuthZ @shared', () => { }, { name: 'Having a junk role fails auth', - requiredRole: '2', + requiredRole: 'server:2', context: { auth: true, role: 'iddqd' }, expectedResult: authFailed(null, new SFE('Your role is not valid')) }, @@ -101,7 +104,10 @@ describe('AuthZ @shared', () => { name: 'Not having the required level fails', requiredRole: 'goku', context: { auth: true, role: '3' }, - expectedResult: authFailed(null, new SFE('You do not have the required role')) + expectedResult: authFailed( + null, + new SFE('You do not have the required goku role') + ) }, { name: 'Having the god mode role defeats even higher privilege requirement', diff --git a/packages/server/modules/stats/graph/resolvers/stats.js b/packages/server/modules/stats/graph/resolvers/stats.js index d45e87bf3..ff2f8e00e 100644 --- a/packages/server/modules/stats/graph/resolvers/stats.js +++ b/packages/server/modules/stats/graph/resolvers/stats.js @@ -1,5 +1,5 @@ 'use strict' -const { validateServerRole, validateScopes } = require('@/modules/shared') +const { validateScopes } = require('@/modules/shared') const { getStreamHistory, getCommitHistory, @@ -10,6 +10,8 @@ const { getTotalObjectCount, getTotalUserCount } = require('../../services') +const { Roles, Scopes } = require('@speckle/shared') +const { throwForNotHavingServerRole } = require('@/modules/shared/authz') module.exports = { Query: { @@ -17,8 +19,8 @@ module.exports = { * @deprecated('Use admin.serverStatistics') */ async serverStats(parent, args, context) { - await validateServerRole(context, 'server:admin') - await validateScopes(context.scopes, 'server:stats') + await throwForNotHavingServerRole(context, Roles.Server.Admin) + await validateScopes(context.scopes, Scopes.Server.Stats) return {} } }, diff --git a/packages/server/modules/stats/tests/stats.spec.js b/packages/server/modules/stats/tests/stats.spec.js index 8758371f3..122ff1a50 100644 --- a/packages/server/modules/stats/tests/stats.spec.js +++ b/packages/server/modules/stats/tests/stats.spec.js @@ -20,6 +20,7 @@ const { getTotalObjectCount, getTotalUserCount } = require('../services') +const { Scopes } = require('@speckle/shared') const params = { numUsers: 25, numStreams: 30, numObjects: 100, numCommits: 100 } @@ -126,24 +127,24 @@ describe('Server stats api @stats-api', function () { adminUser.goodToken = `Bearer ${await createPersonalAccessToken( adminUser.id, 'test token user A', - ['server:stats'] + [Scopes.Server.Stats] )}` adminUser.badToken = `Bearer ${await createPersonalAccessToken( adminUser.id, 'test token user A', - ['streams:read'] + [Scopes.Streams.Read] )}` notAdminUser.id = await createUser(notAdminUser) notAdminUser.goodToken = `Bearer ${await createPersonalAccessToken( notAdminUser.id, 'test token user A', - ['server:stats'] + [Scopes.Server.Stats] )}` notAdminUser.badToken = `Bearer ${await createPersonalAccessToken( notAdminUser.id, 'test token user A', - ['streams:read'] + [Scopes.Streams.Read] )}` await seedDb(params) diff --git a/packages/server/modules/webhooks/graph/resolvers/webhooks.js b/packages/server/modules/webhooks/graph/resolvers/webhooks.js index 90186ac36..faaee85bd 100644 --- a/packages/server/modules/webhooks/graph/resolvers/webhooks.js +++ b/packages/server/modules/webhooks/graph/resolvers/webhooks.js @@ -10,11 +10,12 @@ const { getLastWebhookEvents, getWebhookEventsCount } = require('../../services/webhooks') +const { Roles } = require('@speckle/shared') module.exports = { Stream: { async webhooks(parent, args, context) { - await authorizeResolver(context.userId, parent.id, 'stream:owner') + await authorizeResolver(context.userId, parent.id, Roles.Stream.Owner) if (args.id) { const wh = await getWebhook({ id: args.id }) @@ -41,7 +42,7 @@ module.exports = { Mutation: { async webhookCreate(parent, args, context) { - await authorizeResolver(context.userId, args.webhook.streamId, 'stream:owner') + await authorizeResolver(context.userId, args.webhook.streamId, Roles.Stream.Owner) const id = await createWebhook({ streamId: args.webhook.streamId, @@ -55,7 +56,7 @@ module.exports = { return id }, async webhookUpdate(parent, args, context) { - await authorizeResolver(context.userId, args.webhook.streamId, 'stream:owner') + await authorizeResolver(context.userId, args.webhook.streamId, Roles.Stream.Owner) const wh = await getWebhook({ id: args.webhook.id }) if (args.webhook.streamId !== wh.streamId) @@ -75,7 +76,7 @@ module.exports = { return !!updated }, async webhookDelete(parent, args, context) { - await authorizeResolver(context.userId, args.webhook.streamId, 'stream:owner') + await authorizeResolver(context.userId, args.webhook.streamId, Roles.Stream.Owner) const wh = await getWebhook({ id: args.webhook.id }) if (args.webhook.streamId !== wh.streamId) diff --git a/packages/server/modules/webhooks/tests/webhooks.spec.js b/packages/server/modules/webhooks/tests/webhooks.spec.js index 14ec5f62e..be5904a00 100644 --- a/packages/server/modules/webhooks/tests/webhooks.spec.js +++ b/packages/server/modules/webhooks/tests/webhooks.spec.js @@ -16,6 +16,7 @@ const { } = require('../services/webhooks') const { createUser } = require('../../core/services/users') const { createStream, grantPermissionsStream } = require('../../core/services/streams') +const { Scopes, Roles } = require('@speckle/shared') describe('Webhooks @webhooks', () => { let server, sendRequest, app @@ -139,17 +140,17 @@ describe('Webhooks @webhooks', () => { userOne.token = `Bearer ${await createPersonalAccessToken( userOne.id, 'userOne test token', - ['streams:read', 'streams:write'] + [Scopes.Streams.Read, Scopes.Streams.Write] )}` userTwo.token = `Bearer ${await createPersonalAccessToken( userTwo.id, 'userTwo test token', - ['streams:read', 'streams:write'] + [Scopes.Streams.Read, Scopes.Streams.Write] )}` await grantPermissionsStream({ streamId: streamTwo.id, userId: userOne.id, - role: 'stream:contributor' + role: Roles.Stream.Contributor }) }) diff --git a/packages/server/scripts/duplicateUserMigration.js b/packages/server/scripts/duplicateUserMigration.js index c18946e37..df59462e6 100644 --- a/packages/server/scripts/duplicateUserMigration.js +++ b/packages/server/scripts/duplicateUserMigration.js @@ -1,6 +1,7 @@ const knex = require('@/db/knex') const { logger } = require('@/logging/logging') const roles = require('@/modules/core/roles.js') +const { Roles } = require('@speckle/shared') const Users = () => knex('users') @@ -35,10 +36,10 @@ const migrateColumnValue = async (tableName, columnName, oldUser, newUser) => { const serverAclMigration = async ({ lowerUser, upperUser }) => { const oldAcl = await knex('server_acl').where({ userId: upperUser.id }).first() // if the old user was admin, make the target admin too - if (oldAcl.role === 'server:admin') + if (oldAcl.role === Roles.Server.Admin) await knex('server_acl') .where({ userId: lowerUser.id }) - .update({ role: 'server:admin' }) + .update({ role: Roles.Server.Admin }) } const _migrateSingleStreamAccess = async ({ lowerUser, upperStreamAcl }) => { diff --git a/packages/server/scripts/streamObjects.js b/packages/server/scripts/streamObjects.js index b1c6c1e53..5a4b1e254 100644 --- a/packages/server/scripts/streamObjects.js +++ b/packages/server/scripts/streamObjects.js @@ -9,6 +9,7 @@ const { init } = require(`@/app`) const request = require('supertest') const { exit } = require('yargs') const { logger } = require('@/logging/logging') +const { Scopes } = require('@speckle/shared') const main = async () => { const testStream = { @@ -30,14 +31,14 @@ const main = async () => { userA.id, 'test token user A', [ - 'streams:read', - 'streams:write', - 'users:read', - 'users:email', - 'tokens:write', - 'tokens:read', - 'profile:read', - 'profile:email' + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email ] )}` diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index be4def872..755d23b06 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -1874,6 +1874,7 @@ export type ServerInfo = { canonicalUrl?: Maybe; company?: Maybe; description?: Maybe; + guestModeEnabled: Scalars['Boolean']; inviteOnly?: Maybe; name: Scalars['String']; roles: Array>; @@ -1886,6 +1887,7 @@ export type ServerInfoUpdateInput = { adminContact?: InputMaybe; company?: InputMaybe; description?: InputMaybe; + guestModeEnabled?: InputMaybe; inviteOnly?: InputMaybe; name: Scalars['String']; termsOfService?: InputMaybe; @@ -1906,6 +1908,7 @@ export type ServerInviteCreateInput = { export enum ServerRole { ServerAdmin = 'SERVER_ADMIN', ServerArchivedUser = 'SERVER_ARCHIVED_USER', + ServerGuest = 'SERVER_GUEST', ServerUser = 'SERVER_USER' } diff --git a/packages/shared/src/core/constants.ts b/packages/shared/src/core/constants.ts index 21e4c79a9..ec45aeaa8 100644 --- a/packages/shared/src/core/constants.ts +++ b/packages/shared/src/core/constants.ts @@ -14,6 +14,7 @@ export const Roles = Object.freeze({ Server: { Admin: 'server:admin', User: 'server:user', + Guest: 'server:guest', ArchivedUser: 'server:archived-user' } }) @@ -47,6 +48,10 @@ export const Scopes = Object.freeze({ Tokens: { Read: 'tokens:read', Write: 'tokens:write' + }, + Apps: { + Read: 'apps:read', + Write: 'apps:write' } })