diff --git a/packages/frontend-2/error.vue b/packages/frontend-2/error.vue index 78bc234ad4..f14644ce99 100644 --- a/packages/frontend-2/error.vue +++ b/packages/frontend-2/error.vue @@ -9,6 +9,7 @@ diff --git a/packages/server/assets/automations/typedefs/automation.graphql b/packages/server/assets/automations/typedefs/automation.graphql index f9d9d4c0e5..3b8e374f11 100644 --- a/packages/server/assets/automations/typedefs/automation.graphql +++ b/packages/server/assets/automations/typedefs/automation.graphql @@ -123,9 +123,10 @@ input AutomationRunStatusUpdateInput { type AutomationMutations { functionRunStatusReport(input: AutomationRunStatusUpdateInput!): Boolean! @hasServerRole(role: SERVER_USER) - # @hasScope(scope: "automation:result") # TODO: Consult about this w/ Dim - create(input: AutomationCreateInput!): Boolean! @hasServerRole(role: SERVER_USER) - # @hasScope(scope: "automation:create") # TODO: Consult about this w/ Dim + @hasScope(scope: "automate:report-results") + create(input: AutomationCreateInput!): Boolean! + @hasServerRole(role: SERVER_USER) + @hasScope(scope: "automate:report-results") } extend type Mutation { diff --git a/packages/server/assets/core/typedefs/apitoken.graphql b/packages/server/assets/core/typedefs/apitoken.graphql index 02c4cecb09..d0591417ee 100644 --- a/packages/server/assets/core/typedefs/apitoken.graphql +++ b/packages/server/assets/core/typedefs/apitoken.graphql @@ -24,12 +24,36 @@ type ApiToken { lastUsed: DateTime! #date } +enum TokenResourceIdentifierType { + project +} + +type TokenResourceIdentifier { + id: String! + type: TokenResourceIdentifierType! +} + +input TokenResourceIdentifierInput { + id: String! + type: TokenResourceIdentifierType! +} + input ApiTokenCreateInput { scopes: [String!]! name: String! lifespan: BigInt } +input AppTokenCreateInput { + scopes: [String!]! + name: String! + lifespan: BigInt + """ + Optionally limit the token to only have access to specific resources + """ + limitResources: [TokenResourceIdentifierInput!] +} + extend type Mutation { """ Creates an personal api token. @@ -48,7 +72,7 @@ extend type Mutation { """ Create an app token. Only apps can create app tokens and they don't show up under personal access tokens. """ - appTokenCreate(token: ApiTokenCreateInput!): String! + appTokenCreate(token: AppTokenCreateInput!): String! @hasServerRole(role: SERVER_USER) @hasScope(scope: "tokens:write") } diff --git a/packages/server/assets/core/typedefs/test.graphql b/packages/server/assets/core/typedefs/test.graphql deleted file mode 100644 index 5e7445912a..0000000000 --- a/packages/server/assets/core/typedefs/test.graphql +++ /dev/null @@ -1,9 +0,0 @@ -extend type Query { - testList: [TestItem!]! - testNumber: Int -} - -type TestItem { - foo: String! - bar: String! -} diff --git a/packages/server/modules/accessrequests/graph/resolvers/index.ts b/packages/server/modules/accessrequests/graph/resolvers/index.ts index 18d5090d65..d63a054cc7 100644 --- a/packages/server/modules/accessrequests/graph/resolvers/index.ts +++ b/packages/server/modules/accessrequests/graph/resolvers/index.ts @@ -13,7 +13,7 @@ import { LogicError } from '@/modules/shared/errors' const resolvers: Resolvers = { Mutation: { async streamAccessRequestUse(_parent, args, ctx) { - const { userId } = ctx + const { userId, resourceAccessRules } = ctx const { requestId, accept, role } = args if (!userId) throw new LogicError('User ID unexpectedly false') @@ -22,7 +22,8 @@ const resolvers: Resolvers = { userId, requestId, accept, - mapStreamRoleToValue(role) + mapStreamRoleToValue(role), + resourceAccessRules ) return true }, @@ -67,9 +68,12 @@ const resolvers: Resolvers = { throw new LogicError('Unable to find request stream') } - if (!stream.isPublic) { - await validateStreamAccess(ctx.userId, stream.id, Roles.Stream.Reviewer) - } + await validateStreamAccess( + ctx.userId, + stream.id, + Roles.Stream.Reviewer, + ctx.resourceAccessRules + ) return stream } diff --git a/packages/server/modules/accessrequests/services/stream.ts b/packages/server/modules/accessrequests/services/stream.ts index 0b16a5d1ac..d44e567c0d 100644 --- a/packages/server/modules/accessrequests/services/stream.ts +++ b/packages/server/modules/accessrequests/services/stream.ts @@ -15,6 +15,7 @@ import { ServerAccessRequestRecord } from '@/modules/accessrequests/repositories' import { StreamInvalidAccessError } from '@/modules/core/errors/stream' +import { TokenResourceIdentifier } from '@/modules/core/graph/generated/graphql' import { Roles, StreamRoles } from '@/modules/core/helpers/mainConstants' import { getStream } from '@/modules/core/repositories/streams' import { @@ -22,7 +23,7 @@ import { validateStreamAccess } from '@/modules/core/services/streams/streamAccessService' import { ensureError } from '@/modules/shared/helpers/errorHelper' -import { Nullable } from '@/modules/shared/helpers/typeHelper' +import { MaybeNullOrUndefined, Nullable } from '@/modules/shared/helpers/typeHelper' function buildStreamAccessRequestGraphQLReturn( record: ServerAccessRequestRecord @@ -107,7 +108,8 @@ export async function processPendingStreamRequest( userId: string, requestId: string, accept: boolean, - role: StreamRoles = Roles.Stream.Contributor + role: StreamRoles = Roles.Stream.Contributor, + resourceAccessRules: MaybeNullOrUndefined ) { const req = await getPendingAccessRequest(requestId, AccessRequestType.Stream) if (!req) { @@ -115,7 +117,12 @@ export async function processPendingStreamRequest( } try { - await validateStreamAccess(userId, req.resourceId, Roles.Stream.Owner) + await validateStreamAccess( + userId, + req.resourceId, + Roles.Stream.Owner, + resourceAccessRules + ) } catch (e: unknown) { const err = ensureError(e, 'Stream access validation failed') if (err instanceof StreamInvalidAccessError) { @@ -129,7 +136,13 @@ export async function processPendingStreamRequest( } if (accept) { - await addOrUpdateStreamCollaborator(req.resourceId, req.requesterId, role, userId) + await addOrUpdateStreamCollaborator( + req.resourceId, + req.requesterId, + role, + userId, + resourceAccessRules + ) } await deleteRequestById(req.id) diff --git a/packages/server/modules/activitystream/tests/activity.spec.js b/packages/server/modules/activitystream/tests/activity.spec.js index ab360e2668..bc5d3ab973 100644 --- a/packages/server/modules/activitystream/tests/activity.spec.js +++ b/packages/server/modules/activitystream/tests/activity.spec.js @@ -246,27 +246,27 @@ describe('Activity @activity', () => { const res = await sendRequest(userIz.token, { query: `query {stream(id:"${streamSecret.id}") {name activity {items {id streamId resourceType resourceId actionType userId message time}}} }` }) - expect(res.body.errors.length).to.equal(1) + expect(res.body.errors?.length).to.equal(1) }) it("Should *not* get a stream's activity if you are not a server user", async () => { const res = await sendRequest(null, { query: `query {stream(id:"${streamPublic.id}") {name activity {items {id streamId resourceType resourceId actionType userId message time}}} }` }) - expect(res.body.errors.length).to.equal(1) + expect(res.body.errors?.length).to.equal(1) }) it("Should *not* get a user's activity without the `users:read` scope", async () => { const res = await sendRequest(userX.token, { query: `query {otherUser(id:"${userCr.id}") { name activity {items {id streamId resourceType resourceId actionType userId message time}}} }` }) - expect(res.body.errors.length).to.equal(1) + expect(res.body.errors?.length).to.equal(1) }) it("Should *not* get a user's timeline without the `users:read` scope", async () => { const res = await sendRequest(userX.token, { query: `query {otherUser(id:"${userCr.id}") { name timeline {items {id streamId resourceType resourceId actionType userId message time}}} }` }) - expect(res.body.errors.length).to.equal(1) + expect(res.body.errors?.length).to.equal(1) }) }) diff --git a/packages/server/modules/auth/defaultApps.js b/packages/server/modules/auth/defaultApps.js index 40bf07bcf8..9b26563f14 100644 --- a/packages/server/modules/auth/defaultApps.js +++ b/packages/server/modules/auth/defaultApps.js @@ -214,6 +214,7 @@ const SpeckleAutomate = { ScopesConst.Users.Read, ScopesConst.Tokens.Write, ScopesConst.Streams.Read, - ScopesConst.Streams.Write + ScopesConst.Streams.Write, + ScopesConst.Automate.ReportResults ] } diff --git a/packages/server/modules/automations/index.ts b/packages/server/modules/automations/index.ts index 05d3790c5e..08ae8cef19 100644 --- a/packages/server/modules/automations/index.ts +++ b/packages/server/modules/automations/index.ts @@ -1,9 +1,27 @@ import { moduleLogger } from '@/logging/logging' +import { ScopeRecord } from '@/modules/auth/helpers/types' +import { registerOrUpdateScope } from '@/modules/shared' import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' +import { Scopes } from '@speckle/shared' + +async function initScopes() { + const scopes: ScopeRecord[] = [ + { + name: Scopes.Automate.ReportResults, + description: 'Allows the app to report automation results to the server.', + public: false + } + ] + + for (const scope of scopes) { + await registerOrUpdateScope(scope) + } +} const automationModule: SpeckleModule = { - init() { + async init() { moduleLogger.info('🤖 Init automations module') + await initScopes() } } diff --git a/packages/server/modules/core/dbSchema.ts b/packages/server/modules/core/dbSchema.ts index 6a51fd8067..91bcb78972 100644 --- a/packages/server/modules/core/dbSchema.ts +++ b/packages/server/modules/core/dbSchema.ts @@ -519,4 +519,10 @@ export const ServerApps = buildTableHelper('server_apps', [ export const Scopes = buildTableHelper('scopes', ['name', 'description', 'public']) +export const TokenResourceAccess = buildTableHelper('token_resource_access', [ + 'tokenId', + 'resourceType', + 'resourceId' +]) + export { knex } diff --git a/packages/server/modules/core/graph/directives/hasRole.js b/packages/server/modules/core/graph/directives/hasRole.js index eb250648b8..f343ae8a32 100644 --- a/packages/server/modules/core/graph/directives/hasRole.js +++ b/packages/server/modules/core/graph/directives/hasRole.js @@ -102,7 +102,12 @@ module.exports = { ) } - await authorizeResolver(context.userId, parent.id, requiredRole) + await authorizeResolver( + context.userId, + parent.id, + requiredRole, + context.resourceAccessRules + ) } const data = 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 f8c2711b9b..fc8832d052 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -170,6 +170,14 @@ export type AppCreateInput = { termsAndConditionsLink?: InputMaybe; }; +export type AppTokenCreateInput = { + lifespan?: InputMaybe; + /** Optionally limit the token to only have access to specific resources */ + limitResources?: InputMaybe>; + name: Scalars['String']; + scopes: Array; +}; + export type AppUpdateInput = { description: Scalars['String']; id: Scalars['String']; @@ -1074,7 +1082,7 @@ export type MutationAppRevokeAccessArgs = { export type MutationAppTokenCreateArgs = { - token: ApiTokenCreateInput; + token: AppTokenCreateInput; }; @@ -1876,8 +1884,6 @@ export type Query = { * Pass in the `query` parameter to search by name, description or ID. */ streams?: Maybe; - testList: Array; - testNumber?: Maybe; /** * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). * @deprecated To be removed in the near future! Use 'activeUser' to get info about the active user or 'otherUser' to get info about another user. @@ -2556,12 +2562,21 @@ export type SubscriptionViewerUserActivityBroadcastedArgs = { target: ViewerUpdateTrackingTarget; }; -export type TestItem = { - __typename?: 'TestItem'; - bar: Scalars['String']; - foo: Scalars['String']; +export type TokenResourceIdentifier = { + __typename?: 'TokenResourceIdentifier'; + id: Scalars['String']; + type: TokenResourceIdentifierType; }; +export type TokenResourceIdentifierInput = { + id: Scalars['String']; + type: TokenResourceIdentifierType; +}; + +export enum TokenResourceIdentifierType { + Project = 'project' +} + export type UpdateModelInput = { description?: InputMaybe; id: Scalars['ID']; @@ -2993,6 +3008,7 @@ export type ResolversTypes = { ApiTokenCreateInput: ApiTokenCreateInput; AppAuthor: ResolverTypeWrapper; AppCreateInput: AppCreateInput; + AppTokenCreateInput: AppTokenCreateInput; AppUpdateInput: AppUpdateInput; AuthStrategy: ResolverTypeWrapper; AutomationCreateInput: AutomationCreateInput; @@ -3124,7 +3140,9 @@ export type ResolversTypes = { StreamUpdatePermissionInput: StreamUpdatePermissionInput; String: ResolverTypeWrapper; Subscription: ResolverTypeWrapper<{}>; - TestItem: ResolverTypeWrapper; + TokenResourceIdentifier: ResolverTypeWrapper; + TokenResourceIdentifierInput: TokenResourceIdentifierInput; + TokenResourceIdentifierType: TokenResourceIdentifierType; UpdateModelInput: UpdateModelInput; UpdateVersionInput: UpdateVersionInput; User: ResolverTypeWrapper & { commits?: Maybe, favoriteStreams: ResolversTypes['StreamCollection'], projectInvites: Array, projects: ResolversTypes['ProjectCollection'], streams: ResolversTypes['StreamCollection'] }>; @@ -3168,6 +3186,7 @@ export type ResolversParentTypes = { ApiTokenCreateInput: ApiTokenCreateInput; AppAuthor: AppAuthor; AppCreateInput: AppCreateInput; + AppTokenCreateInput: AppTokenCreateInput; AppUpdateInput: AppUpdateInput; AuthStrategy: AuthStrategy; AutomationCreateInput: AutomationCreateInput; @@ -3285,7 +3304,8 @@ export type ResolversParentTypes = { StreamUpdatePermissionInput: StreamUpdatePermissionInput; String: Scalars['String']; Subscription: {}; - TestItem: TestItem; + TokenResourceIdentifier: TokenResourceIdentifier; + TokenResourceIdentifierInput: TokenResourceIdentifierInput; UpdateModelInput: UpdateModelInput; UpdateVersionInput: UpdateVersionInput; User: Omit & { commits?: Maybe, favoriteStreams: ResolversParentTypes['StreamCollection'], projectInvites: Array, projects: ResolversParentTypes['ProjectCollection'], streams: ResolversParentTypes['StreamCollection'] }; @@ -3989,8 +4009,6 @@ export type QueryResolvers, ParentType, ContextType, RequireFields>; streamInvites?: Resolver, ParentType, ContextType>; streams?: Resolver, ParentType, ContextType, RequireFields>; - testList?: Resolver, ParentType, ContextType>; - testNumber?: Resolver, ParentType, ContextType>; user?: Resolver, ParentType, ContextType, Partial>; userPwdStrength?: Resolver>; userSearch?: Resolver>; @@ -4188,9 +4206,9 @@ export type SubscriptionResolvers>; }; -export type TestItemResolvers = { - bar?: Resolver; - foo?: Resolver; +export type TokenResourceIdentifierResolvers = { + id?: Resolver; + type?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -4402,7 +4420,7 @@ export type Resolvers = { StreamCollaborator?: StreamCollaboratorResolvers; StreamCollection?: StreamCollectionResolvers; Subscription?: SubscriptionResolvers; - TestItem?: TestItemResolvers; + TokenResourceIdentifier?: TokenResourceIdentifierResolvers; User?: UserResolvers; UserProjectsUpdatedMessage?: UserProjectsUpdatedMessageResolvers; UserSearchResultCollection?: UserSearchResultCollectionResolvers; diff --git a/packages/server/modules/core/graph/resolvers/admin.ts b/packages/server/modules/core/graph/resolvers/admin.ts index 87d4aaf594..e3e9cc5c48 100644 --- a/packages/server/modules/core/graph/resolvers/admin.ts +++ b/packages/server/modules/core/graph/resolvers/admin.ts @@ -1,5 +1,6 @@ import { Resolvers } from '@/modules/core/graph/generated/graphql' import { mapServerRoleToValue } from '@/modules/core/helpers/graphTypes' +import { toProjectIdWhitelist } from '@/modules/core/helpers/token' import { adminInviteList, adminProjectList, @@ -20,13 +21,14 @@ export = { role: role ? mapServerRoleToValue(role) : null }) }, - async projectList(_parent, args) { + async projectList(_parent, args, ctx) { return await adminProjectList({ query: args.query ?? null, orderBy: args.orderBy ?? null, visibility: args.visibility ?? null, limit: args.limit, - cursor: args.cursor + cursor: args.cursor, + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules) }) }, serverStatistics: () => ({}), diff --git a/packages/server/modules/core/graph/resolvers/apitoken.js b/packages/server/modules/core/graph/resolvers/apitoken.js index e33fefb9e4..66fe345963 100644 --- a/packages/server/modules/core/graph/resolvers/apitoken.js +++ b/packages/server/modules/core/graph/resolvers/apitoken.js @@ -24,8 +24,10 @@ const resolvers = { Mutation: { async apiTokenCreate(parent, args, context) { canCreatePAT({ - userScopes: context.scopes || [], - tokenScopes: args.token.scopes + scopes: { + user: context.scopes || [], + token: args.token.scopes + } }) return await createPersonalAccessToken( diff --git a/packages/server/modules/core/graph/resolvers/appTokens.ts b/packages/server/modules/core/graph/resolvers/appTokens.ts index b99e18d12f..393266d90d 100644 --- a/packages/server/modules/core/graph/resolvers/appTokens.ts +++ b/packages/server/modules/core/graph/resolvers/appTokens.ts @@ -3,6 +3,23 @@ import { canCreateAppToken } from '@/modules/core/helpers/token' import { getTokenAppInfo } from '@/modules/core/repositories/tokens' import { createAppToken } from '@/modules/core/services/tokens' +/** + * RESOURCE ACCESS CHECKING: + * + * GQL: + * 1. Scopes - if project scope used, also check resource access rules (also - if ask for all projects, filter out the ones that are not allowed) + * 2. Project rights (any directives to check?) - not only check access to project (authorizeResolver?), but also check access rules + * 3. Some resolvers have custom checks, no directives - check manually + * + * REST: + * ??? + * + * - [X] Validate rules before insert? THat way we can rely on them being correct? + * - - Not really, user can change roles after token creation + * + * - [X] What if rules exist, but only for projects? We still want to treat other types as unlimited + */ + export = { Query: { async authenticatedAsApp(_parent, _args, ctx) { @@ -17,11 +34,19 @@ export = { const appId = ctx.appId || '' // validation that this is a valid app id is done in canCreateAppToken canCreateAppToken({ - userScopes: ctx.scopes || [], - tokenScopes: args.token.scopes, + scopes: { + user: ctx.scopes || [], + token: args.token.scopes + }, // both app ids are the same in this scenario, since there's no way to specify a different token app id - userAppId: appId, - tokenAppId: appId + appId: { + user: appId, + token: appId + }, + limitedResources: { + token: args.token.limitResources, + user: ctx.resourceAccessRules + } }) const token = await createAppToken({ diff --git a/packages/server/modules/core/graph/resolvers/branches.js b/packages/server/modules/core/graph/resolvers/branches.js index 93e5e51ddc..097100b179 100644 --- a/packages/server/modules/core/graph/resolvers/branches.js +++ b/packages/server/modules/core/graph/resolvers/branches.js @@ -67,7 +67,8 @@ module.exports = { await authorizeResolver( context.userId, args.branch.streamId, - Roles.Stream.Contributor + Roles.Stream.Contributor, + context.resourceAccessRules ) const { id } = await createBranchAndNotify(args.branch, context.userId) @@ -79,7 +80,8 @@ module.exports = { await authorizeResolver( context.userId, args.branch.streamId, - Roles.Stream.Contributor + Roles.Stream.Contributor, + context.resourceAccessRules ) const newBranch = await updateBranchAndNotify(args.branch, context.userId) @@ -90,7 +92,8 @@ module.exports = { await authorizeResolver( context.userId, args.branch.streamId, - Roles.Stream.Contributor + Roles.Stream.Contributor, + context.resourceAccessRules ) const deleted = await deleteBranchAndNotify(args.branch, context.userId) @@ -105,7 +108,8 @@ module.exports = { await authorizeResolver( context.userId, payload.streamId, - Roles.Stream.Reviewer + Roles.Stream.Reviewer, + context.resourceAccessRules ) return payload.streamId === variables.streamId @@ -120,7 +124,8 @@ module.exports = { await authorizeResolver( context.userId, payload.streamId, - Roles.Stream.Reviewer + Roles.Stream.Reviewer, + context.resourceAccessRules ) const streamMatch = payload.streamId === variables.streamId @@ -140,7 +145,8 @@ module.exports = { await authorizeResolver( context.userId, payload.streamId, - Roles.Stream.Reviewer + Roles.Stream.Reviewer, + context.resourceAccessRules ) 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 5e6fc50bc7..7bdd742b9e 100644 --- a/packages/server/modules/core/graph/resolvers/commits.js +++ b/packages/server/modules/core/graph/resolvers/commits.js @@ -44,6 +44,7 @@ const { } = require('@/modules/core/services/streams/streamAccessService') const { StreamInvalidAccessError } = require('@/modules/core/errors/stream') const { Roles } = require('@speckle/shared') +const { toProjectIdWhitelist } = require('@/modules/core/helpers/token') // subscription events const COMMIT_CREATED = CommitPubsubEvents.CommitCreated @@ -54,10 +55,15 @@ const COMMIT_DELETED = CommitPubsubEvents.CommitDeleted * @param {boolean} publicOnly * @param {string} userId * @param {{limit: number, cursor: string}} args + * @param {string[] | undefined} streamIdWhitelist * @returns */ -const getUserCommits = async (publicOnly, userId, args) => { - const totalCount = await getCommitsTotalCountByUserId({ userId, publicOnly }) +const getUserCommits = async (publicOnly, userId, args, streamIdWhitelist) => { + const totalCount = await getCommitsTotalCountByUserId({ + userId, + publicOnly, + streamIdWhitelist + }) if (args.limit && args.limit > 100) throw new UserInputError( 'Cannot return more than 100 items, please use pagination.' @@ -66,7 +72,8 @@ const getUserCommits = async (publicOnly, userId, args) => { userId, limit: args.limit, cursor: args.cursor, - publicOnly + publicOnly, + streamIdWhitelist }) return { items, cursor, totalCount } @@ -84,7 +91,12 @@ module.exports = { throw new StreamInvalidAccessError('Commit stream not found') } - await validateStreamAccess(ctx.userId, stream.id) + await validateStreamAccess( + ctx.userId, + stream.id, + Roles.Stream.Reviewer, + ctx.resourceAccessRules + ) return stream }, async streamId(parent, _args, ctx) { @@ -149,13 +161,23 @@ module.exports = { } }, LimitedUser: { - async commits(parent, args) { - return await getUserCommits(true, parent.id, args) + async commits(parent, args, ctx) { + return await getUserCommits( + true, + parent.id, + args, + toProjectIdWhitelist(ctx.resourceAccessRules) + ) } }, User: { async commits(parent, args, context) { - return await getUserCommits(context.userId !== parent.id, parent.id, args) + return await getUserCommits( + context.userId !== parent.id, + parent.id, + args, + toProjectIdWhitelist(context.resourceAccessRules) + ) } }, Branch: { @@ -172,7 +194,8 @@ module.exports = { await authorizeResolver( context.userId, args.commit.streamId, - Roles.Stream.Contributor + Roles.Stream.Contributor, + context.resourceAccessRules ) const rateLimitResult = await getRateLimitResult( @@ -195,7 +218,8 @@ module.exports = { await authorizeResolver( context.userId, args.commit.streamId, - Roles.Stream.Contributor + Roles.Stream.Contributor, + context.resourceAccessRules ) await updateCommitAndNotify(args.commit, context.userId) @@ -206,7 +230,8 @@ module.exports = { await authorizeResolver( context.userId, args.input.streamId, - Roles.Stream.Reviewer + Roles.Stream.Reviewer, + context.resourceAccessRules ) const commit = await getCommitById({ @@ -227,7 +252,8 @@ module.exports = { await authorizeResolver( context.userId, args.commit.streamId, - Roles.Stream.Contributor + Roles.Stream.Contributor, + context.resourceAccessRules ) const deleted = await deleteCommitAndNotify( @@ -256,7 +282,8 @@ module.exports = { await authorizeResolver( context.userId, payload.streamId, - Roles.Stream.Reviewer + Roles.Stream.Reviewer, + context.resourceAccessRules ) return payload.streamId === variables.streamId } @@ -270,7 +297,8 @@ module.exports = { await authorizeResolver( context.userId, payload.streamId, - Roles.Stream.Reviewer + Roles.Stream.Reviewer, + context.resourceAccessRules ) const streamMatch = payload.streamId === variables.streamId @@ -290,7 +318,8 @@ module.exports = { await authorizeResolver( context.userId, payload.streamId, - Roles.Stream.Reviewer + Roles.Stream.Reviewer, + context.resourceAccessRules ) return payload.streamId === variables.streamId diff --git a/packages/server/modules/core/graph/resolvers/models.ts b/packages/server/modules/core/graph/resolvers/models.ts index f1b4457622..03d9738b52 100644 --- a/packages/server/modules/core/graph/resolvers/models.ts +++ b/packages/server/modules/core/graph/resolvers/models.ts @@ -127,7 +127,8 @@ export = { await authorizeResolver( ctx.userId, args.input.projectId, - Roles.Stream.Contributor + Roles.Stream.Contributor, + ctx.resourceAccessRules ) return await createBranchAndNotify(args.input, ctx.userId!) }, @@ -135,7 +136,8 @@ export = { await authorizeResolver( ctx.userId, args.input.projectId, - Roles.Stream.Contributor + Roles.Stream.Contributor, + ctx.resourceAccessRules ) return await updateBranchAndNotify(args.input, ctx.userId!) }, @@ -143,7 +145,8 @@ export = { await authorizeResolver( ctx.userId, args.input.projectId, - Roles.Stream.Contributor + Roles.Stream.Contributor, + ctx.resourceAccessRules ) return await deleteBranchAndNotify(args.input, ctx.userId!) } @@ -156,7 +159,12 @@ export = { const { id: projectId, modelIds } = args if (payload.projectId !== projectId) return false - await authorizeResolver(ctx.userId, projectId, Roles.Stream.Reviewer) + await authorizeResolver( + ctx.userId, + projectId, + Roles.Stream.Reviewer, + ctx.resourceAccessRules + ) if (!modelIds?.length) return true return modelIds.includes(payload.projectModelsUpdated.id) } diff --git a/packages/server/modules/core/graph/resolvers/objects.js b/packages/server/modules/core/graph/resolvers/objects.js index 3019b02b4f..6ad147b8c8 100644 --- a/packages/server/modules/core/graph/resolvers/objects.js +++ b/packages/server/modules/core/graph/resolvers/objects.js @@ -62,7 +62,8 @@ module.exports = { await authorizeResolver( context.userId, args.objectInput.streamId, - Roles.Stream.Contributor + Roles.Stream.Contributor, + context.resourceAccessRules ) 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 2bffad5ffb..192d4c07ae 100644 --- a/packages/server/modules/core/graph/resolvers/projects.ts +++ b/packages/server/modules/core/graph/resolvers/projects.ts @@ -1,7 +1,12 @@ import { RateLimitError } from '@/modules/core/errors/ratelimit' import { StreamNotFoundError } from '@/modules/core/errors/stream' -import { ProjectVisibility, Resolvers } from '@/modules/core/graph/generated/graphql' +import { + ProjectVisibility, + Resolvers, + TokenResourceIdentifierType +} from '@/modules/core/graph/generated/graphql' import { Roles, Scopes, StreamRoles } from '@/modules/core/helpers/mainConstants' +import { isResourceAllowed, toProjectIdWhitelist } from '@/modules/core/helpers/token' import { getUserStreamsCount, getUserStreams, @@ -50,7 +55,12 @@ export = { throw new StreamNotFoundError('Project not found') } - await authorizeResolver(context.userId, args.id, Roles.Stream.Reviewer) + await authorizeResolver( + context.userId, + args.id, + Roles.Stream.Reviewer, + context.resourceAccessRules + ) if (!stream.isPublic) { await throwForNotHavingServerRole(context, Roles.Server.Guest) @@ -64,16 +74,14 @@ export = { projectMutations: () => ({}) }, ProjectMutations: { - async delete(_parent, { id }, { userId }) { - await authorizeResolver(userId, id, Roles.Stream.Owner) - return await deleteStreamAndNotify(id, userId!) + async delete(_parent, { id }, { userId, resourceAccessRules }) { + return await deleteStreamAndNotify(id, userId!, resourceAccessRules) }, - async createForOnboarding(_parent, _args, { userId }) { - return await createOnboardingStream(userId!) + async createForOnboarding(_parent, _args, { userId, resourceAccessRules }) { + return await createOnboardingStream(userId!, resourceAccessRules) }, - async update(_parent, { update }, { userId }) { - await authorizeResolver(userId, update.id, Roles.Stream.Owner) - return await updateStreamAndNotify(update, userId!) + async update(_parent, { update }, { userId, resourceAccessRules }) { + return await updateStreamAndNotify(update, userId!, resourceAccessRules) }, async create(_parent, args, context) { const rateLimitResult = await getRateLimitResult( @@ -85,45 +93,70 @@ export = { } const project = await createStreamReturnRecord( - { ...(args.input || {}), ownerId: context.userId! }, + { + ...(args.input || {}), + ownerId: context.userId!, + ownerResourceAccessRules: context.resourceAccessRules + }, { createActivity: true } ) return project }, async updateRole(_parent, args, ctx) { - await authorizeResolver(ctx.userId, args.input.projectId, Roles.Stream.Owner) - return await updateStreamRoleAndNotify(args.input, ctx.userId!) + await authorizeResolver( + ctx.userId, + args.input.projectId, + Roles.Stream.Owner, + ctx.resourceAccessRules + ) + return await updateStreamRoleAndNotify( + args.input, + ctx.userId!, + ctx.resourceAccessRules + ) }, async leave(_parent, args, context) { const { id } = args const { userId } = context - await removeStreamCollaborator(id, userId!, userId!) + await removeStreamCollaborator(id, userId!, userId!, context.resourceAccessRules) return true }, invites: () => ({}) }, ProjectInviteMutations: { async create(_parent, args, ctx) { - await authorizeResolver(ctx.userId!, args.projectId, Roles.Stream.Owner) + await authorizeResolver( + ctx.userId!, + args.projectId, + Roles.Stream.Owner, + ctx.resourceAccessRules + ) await createStreamInviteAndNotify( { ...args.input, projectId: args.projectId }, - ctx.userId! + ctx.userId!, + ctx.resourceAccessRules ) return ctx.loaders.streams.getStream.load(args.projectId) }, async batchCreate(_parent, args, ctx) { - await authorizeResolver(ctx.userId!, args.projectId, Roles.Stream.Owner) + await authorizeResolver( + ctx.userId!, + args.projectId, + Roles.Stream.Owner, + ctx.resourceAccessRules + ) const inputBatches = chunk(args.input, 10) for (const batch of inputBatches) { await Promise.all( batch.map((i) => createStreamInviteAndNotify( { ...i, projectId: args.projectId }, - ctx.userId! + ctx.userId!, + ctx.resourceAccessRules ) ) ) @@ -131,11 +164,16 @@ export = { return ctx.loaders.streams.getStream.load(args.projectId) }, async use(_parent, args, ctx) { - await useStreamInviteAndNotify(args.input, ctx.userId!) + await useStreamInviteAndNotify(args.input, ctx.userId!, ctx.resourceAccessRules) return true }, async cancel(_parent, args, ctx) { - await authorizeResolver(ctx.userId, args.projectId, Roles.Stream.Owner) + await authorizeResolver( + ctx.userId, + args.projectId, + Roles.Stream.Owner, + ctx.resourceAccessRules + ) await cancelStreamInvite(args.projectId, args.inviteId) return ctx.loaders.streams.getStream.load(args.projectId) } @@ -146,7 +184,8 @@ export = { userId: ctx.userId!, forOtherUser: false, searchQuery: args.filter?.search || undefined, - withRoles: (args.filter?.onlyWithRoles || []) as StreamRoles[] + withRoles: (args.filter?.onlyWithRoles || []) as StreamRoles[], + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules) }) const { cursor, streams } = await getUserStreams({ @@ -155,7 +194,8 @@ export = { cursor: args.cursor || undefined, searchQuery: args.filter?.search || undefined, forOtherUser: false, - withRoles: (args.filter?.onlyWithRoles || []) as StreamRoles[] + withRoles: (args.filter?.onlyWithRoles || []) as StreamRoles[], + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules) }) return { totalCount, cursor, items: streams } @@ -206,6 +246,15 @@ export = { subscribe: filteredSubscribe( UserSubscriptions.UserProjectsUpdated, (payload, _args, ctx) => { + const hasResourceAccess = isResourceAllowed({ + resourceId: payload.userProjectsUpdated.id, + resourceType: TokenResourceIdentifierType.Project, + resourceAccessRules: ctx.resourceAccessRules + }) + if (!hasResourceAccess) { + return false + } + return payload.ownerId === ctx.userId } ) @@ -218,7 +267,8 @@ export = { await authorizeResolver( ctx.userId, payload.projectUpdated.id, - Roles.Stream.Reviewer + Roles.Stream.Reviewer, + ctx.resourceAccessRules ) return true } diff --git a/packages/server/modules/core/graph/resolvers/streams.js b/packages/server/modules/core/graph/resolvers/streams.js index 2aaccda261..13069d2a4c 100644 --- a/packages/server/modules/core/graph/resolvers/streams.js +++ b/packages/server/modules/core/graph/resolvers/streams.js @@ -50,6 +50,15 @@ const { adminOverrideEnabled } = require('@/modules/shared/helpers/envHelper') const { Roles, Scopes } = require('@speckle/shared') const { StreamNotFoundError } = require('@/modules/core/errors/stream') const { throwForNotHavingServerRole } = require('@/modules/shared/authz') +const { logger } = require('@/logging/logging') + +const { + toProjectIdWhitelist, + isResourceAllowed +} = require('@/modules/core/helpers/token') +const { + TokenResourceIdentifierType +} = require('@/modules/core/graph/generated/graphql') // subscription events const USER_STREAM_ADDED = StreamPubsubEvents.UserStreamAdded @@ -57,18 +66,29 @@ const USER_STREAM_REMOVED = StreamPubsubEvents.UserStreamRemoved const STREAM_UPDATED = StreamPubsubEvents.StreamUpdated const STREAM_DELETED = StreamPubsubEvents.StreamDeleted -const _deleteStream = async (_parent, args, context) => { - return await deleteStreamAndNotify(args.id, context.userId) +const _deleteStream = async (_parent, args, context, options) => { + const { skipAccessChecks = false } = options || {} + return await deleteStreamAndNotify( + args.id, + context.userId, + context.resourceAccessRules, + { skipAccessChecks } + ) } -const getUserStreamsCore = async (forOtherUser, parent, args) => { - const totalCount = await getUserStreamsCount({ userId: parent.id, forOtherUser }) +const getUserStreamsCore = async (forOtherUser, parent, args, streamIdWhitelist) => { + const totalCount = await getUserStreamsCount({ + userId: parent.id, + forOtherUser, + streamIdWhitelist + }) const { cursor, streams } = await getUserStreams({ userId: parent.id, limit: args.limit, cursor: args.cursor, - forOtherUser + forOtherUser, + streamIdWhitelist }) return { totalCount, cursor, items: streams } @@ -85,7 +105,12 @@ module.exports = { throw new StreamNotFoundError('Stream not found') } - await authorizeResolver(context.userId, args.id, Roles.Stream.Reviewer) + await authorizeResolver( + context.userId, + args.id, + Roles.Stream.Reviewer, + context.resourceAccessRules + ) if (!stream.isPublic) { await throwForNotHavingServerRole(context, Roles.Server.Guest) @@ -98,20 +123,25 @@ module.exports = { async streams(parent, args, context) { const totalCount = await getUserStreamsCount({ userId: context.userId, - searchQuery: args.query + searchQuery: args.query, + streamIdWhitelist: toProjectIdWhitelist(context.resourceAccessRules) }) const { cursor, streams } = await getUserStreams({ userId: context.userId, limit: args.limit, cursor: args.cursor, - searchQuery: args.query + searchQuery: args.query, + streamIdWhitelist: toProjectIdWhitelist(context.resourceAccessRules) }) return { totalCount, cursor, items: streams } }, - async discoverableStreams(parent, args) { - return await getDiscoverableStreams(args) + async discoverableStreams(parent, args, ctx) { + return await getDiscoverableStreams( + args, + toProjectIdWhitelist(ctx.resourceAccessRules) + ) }, async adminStreams(parent, args) { @@ -124,7 +154,8 @@ module.exports = { orderBy: args.orderBy, publicOnly: args.publicOnly, searchQuery: args.query, - visibility: args.visibility + visibility: args.visibility, + streamIdWhitelist: toProjectIdWhitelist(args.resourceAccessRules) }) return { totalCount, items: streams } } @@ -171,7 +202,12 @@ module.exports = { async streams(parent, args, context) { // Return only the user's public streams if parent.id !== context.userId const forOtherUser = parent.id !== context.userId - return await getUserStreamsCore(forOtherUser, parent, args) + return await getUserStreamsCore( + forOtherUser, + parent, + args, + toProjectIdWhitelist(context.resourceAccessRules) + ) }, async favoriteStreams(parent, args, context) { @@ -182,7 +218,12 @@ module.exports = { if (userId !== requestedUserId) throw new UserInputError("Cannot view another user's favorite streams") - return await getFavoriteStreamsCollection({ userId, limit, cursor }) + return await getFavoriteStreamsCollection({ + userId, + limit, + cursor, + streamIdWhitelist: toProjectIdWhitelist(context.resourceAccessRules) + }) }, async totalOwnedStreamsFavorites(parent, _args, ctx) { @@ -195,7 +236,12 @@ module.exports = { // a little escape hatch for admins to look into users streams const isAdmin = adminOverrideEnabled() && context.role === Roles.Server.Admin - return await getUserStreamsCore(!isAdmin, parent, args) + return await getUserStreamsCore( + !isAdmin, + parent, + args, + toProjectIdWhitelist(context.resourceAccessRules) + ) }, async totalOwnedStreamsFavorites(parent, _args, ctx) { const { id: userId } = parent @@ -213,7 +259,11 @@ module.exports = { } const { id } = await createStreamReturnRecord( - { ...args.stream, ownerId: context.userId }, + { + ...args.stream, + ownerId: context.userId, + ownerResourceAccessRules: context.resourceAccessRules + }, { createActivity: true } ) @@ -221,67 +271,66 @@ module.exports = { }, async streamUpdate(parent, args, context) { - await authorizeResolver(context.userId, args.stream.id, Roles.Stream.Owner) - await updateStreamAndNotify(args.stream, context.userId) + await updateStreamAndNotify( + args.stream, + context.userId, + context.resourceAccessRules + ) return true }, - async streamDelete(parent, args, context, info) { - await authorizeResolver(context.userId, args.id, Roles.Stream.Owner) - return await _deleteStream(parent, args, context, info) + async streamDelete(parent, args, context) { + return await _deleteStream(parent, args, context) }, - async streamsDelete(parent, args, context, info) { + async streamsDelete(parent, args, context) { const results = await Promise.all( args.ids.map(async (id) => { const newArgs = { ...args } newArgs.id = id - return await _deleteStream(parent, newArgs, context, info) + return await _deleteStream(parent, newArgs, context, { + skipAccessChecks: true + }) }) ) return results.every((res) => res === true) }, async streamUpdatePermission(parent, args, context) { - await authorizeResolver( - context.userId, - args.permissionParams.streamId, - Roles.Stream.Owner - ) - const result = await updateStreamRoleAndNotify( args.permissionParams, - context.userId + context.userId, + context.resourceAccessRules ) return !!result }, async streamRevokePermission(parent, args, context) { - await authorizeResolver( - context.userId, - args.permissionParams.streamId, - Roles.Stream.Owner - ) - const result = await updateStreamRoleAndNotify( args.permissionParams, - context.userId + context.userId, + context.resourceAccessRules ) return !!result }, async streamFavorite(_parent, args, ctx) { const { streamId, favorited } = args - const { userId } = ctx + const { userId, resourceAccessRules } = ctx - return await favoriteStream({ userId, streamId, favorited }) + return await favoriteStream({ + userId, + streamId, + favorited, + userResourceAccessRules: resourceAccessRules + }) }, async streamLeave(_parent, args, ctx) { const { streamId } = args const { userId } = ctx - await removeStreamCollaborator(streamId, userId, userId) + await removeStreamCollaborator(streamId, userId, userId, ctx.resourceAccessRules) return true } @@ -292,6 +341,17 @@ module.exports = { subscribe: withFilter( () => pubsub.asyncIterator([USER_STREAM_ADDED]), (payload, variables, context) => { + logger.info('whattt') + const hasResourceAccess = isResourceAllowed({ + resourceId: payload.userStreamAdded.id, + resourceType: TokenResourceIdentifierType.Project, + resourceAccessRules: context.resourceAccessRules + }) + + if (!hasResourceAccess) { + return false + } + return payload.ownerId === context.userId } ) @@ -301,6 +361,15 @@ module.exports = { subscribe: withFilter( () => pubsub.asyncIterator([USER_STREAM_REMOVED]), (payload, variables, context) => { + const hasResourceAccess = isResourceAllowed({ + resourceId: payload.userStreamRemoved.id, + resourceType: TokenResourceIdentifierType.Project, + resourceAccessRules: context.resourceAccessRules + }) + if (!hasResourceAccess) { + return false + } + return payload.ownerId === context.userId } ) @@ -310,7 +379,12 @@ module.exports = { subscribe: withFilter( () => pubsub.asyncIterator([STREAM_UPDATED]), async (payload, variables, context) => { - await authorizeResolver(context.userId, payload.id, Roles.Stream.Reviewer) + await authorizeResolver( + context.userId, + payload.id, + Roles.Stream.Reviewer, + context.resourceAccessRules + ) return payload.id === variables.streamId } ) @@ -323,7 +397,8 @@ module.exports = { await authorizeResolver( context.userId, payload.streamId, - Roles.Stream.Reviewer + Roles.Stream.Reviewer, + context.resourceAccessRules ) return payload.streamId === variables.streamId } diff --git a/packages/server/modules/core/graph/resolvers/versions.ts b/packages/server/modules/core/graph/resolvers/versions.ts index ad3113bf4b..d6254de0bf 100644 --- a/packages/server/modules/core/graph/resolvers/versions.ts +++ b/packages/server/modules/core/graph/resolvers/versions.ts @@ -48,7 +48,12 @@ export = { throw new CommitUpdateError('Commit stream not found') } - await authorizeResolver(ctx.userId!, stream.id, Roles.Stream.Contributor) + await authorizeResolver( + ctx.userId!, + stream.id, + Roles.Stream.Contributor, + ctx.resourceAccessRules + ) return await updateCommitAndNotify(args.input, ctx.userId!) } }, @@ -59,7 +64,12 @@ export = { async (payload, args, ctx) => { if (payload.projectId !== args.id) return false - await authorizeResolver(ctx.userId, payload.projectId, Roles.Stream.Reviewer) + await authorizeResolver( + ctx.userId, + payload.projectId, + Roles.Stream.Reviewer, + ctx.resourceAccessRules + ) return true } ) @@ -73,7 +83,8 @@ export = { await authorizeResolver( ctx.userId, payload.projectVersionsPreviewGenerated.projectId, - Roles.Stream.Reviewer + Roles.Stream.Reviewer, + ctx.resourceAccessRules ) return true } diff --git a/packages/server/modules/core/helpers/token.ts b/packages/server/modules/core/helpers/token.ts index b767c72402..9cf24c43b1 100644 --- a/packages/server/modules/core/helpers/token.ts +++ b/packages/server/modules/core/helpers/token.ts @@ -1,27 +1,118 @@ import { TokenCreateError } from '@/modules/core/errors/user' -import { Scopes } from '@speckle/shared' +import { + TokenResourceIdentifier, + TokenResourceIdentifierType +} from '@/modules/core/graph/generated/graphql' +import { TokenResourceAccessRecord } from '@/modules/core/helpers/types' +import { ResourceTargets } from '@/modules/serverinvites/helpers/inviteHelper' +import { MaybeNullOrUndefined, Nullable, Optional, Scopes } from '@speckle/shared' +import { differenceBy } from 'lodash' -export const canCreateToken = (params: { - userScopes: string[] - tokenScopes: string[] +export type ContextResourceAccessRules = MaybeNullOrUndefined + +export const resourceAccessRuleToIdentifier = ( + rule: TokenResourceAccessRecord +): TokenResourceIdentifier => { + return { + id: rule.resourceId, + type: rule.resourceType + } +} + +export const roleResourceTypeToTokenResourceType = ( + type: string +): Nullable => { + switch (type) { + case ResourceTargets.Streams: + return TokenResourceIdentifierType.Project + default: + return null + } +} + +export const isResourceAllowed = (params: { + resourceId: string + resourceType: TokenResourceIdentifierType + resourceAccessRules?: MaybeNullOrUndefined }) => { - const { userScopes, tokenScopes } = params - const hasAllScopes = tokenScopes.every((scope) => userScopes.includes(scope)) + const { resourceId, resourceType, resourceAccessRules } = params + const relevantRules = resourceAccessRules?.filter((r) => r.type === resourceType) + return !relevantRules?.length || relevantRules.some((r) => r.id === resourceId) +} + +export const isNewResourceAllowed = (params: { + resourceType: TokenResourceIdentifierType + resourceAccessRules?: MaybeNullOrUndefined +}) => { + const { resourceType, resourceAccessRules } = params + const relevantRules = resourceAccessRules?.filter((r) => r.type === resourceType) + return !relevantRules?.length +} + +export const toProjectIdWhitelist = ( + resourceAccessRules: ContextResourceAccessRules +): Optional => { + const projectRules = resourceAccessRules?.filter( + (r) => r.type === TokenResourceIdentifierType.Project + ) + return projectRules?.map((r) => r.id) +} + +const canCreateToken = (params: { + scopes: { + user: string[] + token: string[] + } + limitedResources?: { + user: MaybeNullOrUndefined + token: MaybeNullOrUndefined + } +}) => { + const { scopes, limitedResources } = params + const hasAllScopes = scopes.token.every((scope) => scopes.user.includes(scope)) if (!hasAllScopes) { throw new TokenCreateError( "You can't create a token with scopes that you don't have" ) } + const userLimitedResources = limitedResources?.user + const tokenLimitedResources = limitedResources?.token + + let throwAboutInvalidResources = false + if (userLimitedResources?.length || tokenLimitedResources?.length) { + if (userLimitedResources?.length && !tokenLimitedResources?.length) { + throwAboutInvalidResources = true + } else if (userLimitedResources?.length) { + const disallowedResources = differenceBy( + tokenLimitedResources || [], + userLimitedResources || [], + (r) => `${r.type}:${r.id}` + ) + + if (disallowedResources.length) { + throwAboutInvalidResources = true + } + } + } + + if (throwAboutInvalidResources) { + throw new TokenCreateError( + `You can't create a token with access to resources that you don't currently have access to` + ) + } + return true } export const canCreatePAT = (params: { - userScopes: string[] - tokenScopes: string[] + scopes: { + user: string[] + token: string[] + } }) => { - const { tokenScopes } = params - if (tokenScopes.includes(Scopes.Tokens.Write)) { + const { scopes } = params + if (scopes.token.includes(Scopes.Tokens.Write)) { throw new TokenCreateError( "You can't create a personal access token with the tokens:write scope" ) @@ -31,13 +122,21 @@ export const canCreatePAT = (params: { } export const canCreateAppToken = (params: { - userScopes: string[] - tokenScopes: string[] - userAppId: string - tokenAppId: string + scopes: { + user: string[] + token: string[] + } + appId: { + user: string + token: string + } + limitedResources: { + user: MaybeNullOrUndefined + token: MaybeNullOrUndefined + } }) => { - const { userAppId, tokenAppId } = params - if (userAppId !== tokenAppId || !tokenAppId?.length || !userAppId?.length) { + const { appId } = params + if (appId.user !== appId.token || !appId.token?.length || !appId.user?.length) { throw new TokenCreateError( 'An app token can only create a new token for the same app' ) diff --git a/packages/server/modules/core/helpers/types.ts b/packages/server/modules/core/helpers/types.ts index 400b347688..f2b2d577bf 100644 --- a/packages/server/modules/core/helpers/types.ts +++ b/packages/server/modules/core/helpers/types.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql' import { BaseMetaRecord } from '@/modules/core/helpers/meta' import { Nullable } from '@/modules/shared/helpers/typeHelper' import { ServerRoles } from '@speckle/shared' @@ -142,6 +143,10 @@ export type ValidTokenResult = { * Set, if the token is an app token */ appId: Nullable + /** + * Set, if the token has resource access limits (e.g. only access to specific projects) + */ + resourceAccessRules: Nullable } export type TokenValidationResult = InvalidTokenResult | ValidTokenResult @@ -164,3 +169,9 @@ export type ServerAppRecord = { createdAt: Date redirectUrl: string } + +export type TokenResourceAccessRecord = { + tokenId: string + resourceId: string + resourceType: TokenResourceIdentifierType +} diff --git a/packages/server/modules/core/migrations/20240109101048_create_token_resource_access_table.ts b/packages/server/modules/core/migrations/20240109101048_create_token_resource_access_table.ts new file mode 100644 index 0000000000..65801251a8 --- /dev/null +++ b/packages/server/modules/core/migrations/20240109101048_create_token_resource_access_table.ts @@ -0,0 +1,25 @@ +import { Knex } from 'knex' + +const TABLE_NAME = 'token_resource_access' +const TOKENS_TABLE_NAME = 'api_tokens' + +export async function up(knex: Knex): Promise { + await knex.schema.createTable(TABLE_NAME, (table) => { + table + .string('tokenId', 10) + .notNullable() + .references('id') + .inTable(TOKENS_TABLE_NAME) + .onDelete('cascade') + + table.string('resourceId').notNullable() + table.string('resourceType').notNullable() + + // Add idx to tokenId + table.index('tokenId') + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable(TABLE_NAME) +} diff --git a/packages/server/modules/core/repositories/streams.ts b/packages/server/modules/core/repositories/streams.ts index 9798ea9969..cdc4a0764f 100644 --- a/packages/server/modules/core/repositories/streams.ts +++ b/packages/server/modules/core/repositories/streams.ts @@ -201,7 +201,7 @@ export async function getCommitStream(params: { commitId: string; userId?: strin */ function getFavoritedStreamsQueryBase< Result = Array ->(userId: string) { +>(userId: string, streamIdWhitelist?: Optional) { if (!userId) throw new InvalidArgumentError( 'User ID must be specified to retrieve favorited streams' @@ -219,6 +219,10 @@ function getFavoritedStreamsQueryBase< q.where(Streams.col.isPublic, true).orWhereNotNull(StreamAcl.col.resourceId) ) + if (streamIdWhitelist?.length) { + query.whereIn(Streams.col.id, streamIdWhitelist) + } + return query } @@ -233,13 +237,13 @@ export async function getFavoritedStreams(params: { userId: string cursor?: string limit?: number + streamIdWhitelist?: Optional }) { - const { userId, cursor, limit } = params + const { userId, cursor, limit, streamIdWhitelist } = params const finalLimit = _.clamp(limit || 25, 1, 25) - const query = - getFavoritedStreamsQueryBase< - Array - >(userId) + const query = getFavoritedStreamsQueryBase< + Array + >(userId, streamIdWhitelist) query .select([ ...STREAM_WITH_OPTIONAL_ROLE_COLUMNS, @@ -262,8 +266,14 @@ export async function getFavoritedStreams(params: { /** * Get total amount of streams favorited by user */ -export async function getFavoritedStreamsCount(userId: string) { - const query = getFavoritedStreamsQueryBase<[{ count: string }]>(userId) +export async function getFavoritedStreamsCount( + userId: string, + streamIdWhitelist?: Optional +) { + const query = getFavoritedStreamsQueryBase<[{ count: string }]>( + userId, + streamIdWhitelist + ) query.count() const [res] = await query @@ -462,14 +472,24 @@ export async function getStreamRoles(userId: string, streamIds: string[]) { export type GetDiscoverableStreamsParams = Required & { sort: DiscoverableStreamsSortingInput + /** + * Only allow streams with the specified IDs to be returned + */ + streamIdWhitelist?: string[] } -function buildDiscoverableStreamsBaseQuery>() { +function buildDiscoverableStreamsBaseQuery>( + params: GetDiscoverableStreamsParams +) { const q = Streams.knex() .select(Streams.cols) .where(Streams.col.isDiscoverable, true) .andWhere(Streams.col.isPublic, true) + if (params.streamIdWhitelist?.length) { + q.whereIn(Streams.col.id, params.streamIdWhitelist) + } + return q } @@ -534,8 +554,8 @@ export const encodeDiscoverableStreamsCursor = ( /** * Counts all discoverable streams */ -export async function countDiscoverableStreams() { - const q = buildDiscoverableStreamsBaseQuery<{ count: string }[]>() +export async function countDiscoverableStreams(params: GetDiscoverableStreamsParams) { + const q = buildDiscoverableStreamsBaseQuery<{ count: string }[]>(params) q.clearSelect() q.count() @@ -548,7 +568,7 @@ export async function countDiscoverableStreams() { */ export async function getDiscoverableStreams(params: GetDiscoverableStreamsParams) { const { cursor, sort, limit } = params - const q = buildDiscoverableStreamsBaseQuery().limit(limit) + const q = buildDiscoverableStreamsBaseQuery(params).limit(limit) const decodedCursor = cursor ? decodeDiscoverableStreamsCursor(sort.type, cursor) @@ -625,6 +645,11 @@ type BaseUserStreamsQueryParams = { * Only return streams where user has the specified roles */ withRoles?: StreamRoles[] + + /** + * Only allow streams with the specified IDs to be returned + */ + streamIdWhitelist?: string[] } export type UserStreamsQueryParams = BaseUserStreamsQueryParams & { @@ -650,7 +675,8 @@ function getUserStreamsQueryBase< searchQuery, forOtherUser, ownedOnly, - withRoles + withRoles, + streamIdWhitelist }: BaseUserStreamsQueryParams) { const query = StreamAcl.knex>() .where(StreamAcl.col.userId, userId) @@ -670,12 +696,17 @@ function getUserStreamsQueryBase< .andWhere(Streams.col.isPublic, true) } - if (searchQuery) + if (searchQuery) { query.andWhere(function () { this.where(Streams.col.name, 'ILIKE', `%${searchQuery}%`) .orWhere(Streams.col.description, 'ILIKE', `%${searchQuery}%`) .orWhere(Streams.col.id, 'ILIKE', `%${searchQuery}%`) //potentially useless? }) + } + + if (streamIdWhitelist?.length) { + query.whereIn(Streams.col.id, streamIdWhitelist) + } return query } diff --git a/packages/server/modules/core/rest/authUtils.js b/packages/server/modules/core/rest/authUtils.js index d9895c060d..6e365bb4af 100644 --- a/packages/server/modules/core/rest/authUtils.js +++ b/packages/server/modules/core/rest/authUtils.js @@ -30,7 +30,12 @@ module.exports = { } try { - await authorizeResolver(req.context.userId, streamId, Roles.Stream.Reviewer) + await authorizeResolver( + req.context.userId, + streamId, + Roles.Stream.Reviewer, + req.context.resourceAccessRules + ) } catch (err) { return { result: false, status: 401 } } @@ -56,7 +61,12 @@ module.exports = { } try { - await authorizeResolver(req.context.userId, streamId, Roles.Stream.Contributor) + await authorizeResolver( + req.context.userId, + streamId, + Roles.Stream.Contributor, + req.context.resourceAccessRules + ) } catch (err) { return { result: false, status: 401 } } diff --git a/packages/server/modules/core/services/admin.ts b/packages/server/modules/core/services/admin.ts index a6023bb715..6d4dddca36 100644 --- a/packages/server/modules/core/services/admin.ts +++ b/packages/server/modules/core/services/admin.ts @@ -97,13 +97,14 @@ export const adminInviteList = async ( } export const adminProjectList = async ( - args: AdminProjectListArgs + args: AdminProjectListArgs & { streamIdWhitelist?: string[] } ): Promise> => { const parsedCursor = args.cursor ? parseCursorToDate(args.cursor) : null const { streams, totalCount, cursorDate } = await getStreams({ ...args, searchQuery: args.query, - cursor: parsedCursor + cursor: parsedCursor, + streamIdWhitelist: args.streamIdWhitelist }) const cursor = cursorDate ? convertDateToCursor(cursorDate) : null return { diff --git a/packages/server/modules/core/services/commits.js b/packages/server/modules/core/services/commits.js index 5f7497935b..424006db84 100644 --- a/packages/server/modules/core/services/commits.js +++ b/packages/server/modules/core/services/commits.js @@ -18,7 +18,7 @@ const { } = require('@/modules/core/services/commit/management') const { clamp } = require('lodash') -const getCommitsByUserIdBase = ({ userId, publicOnly }) => { +const getCommitsByUserIdBase = ({ userId, publicOnly, streamIdWhitelist }) => { publicOnly = publicOnly !== false const query = Commits() @@ -46,6 +46,7 @@ const getCommitsByUserIdBase = ({ userId, publicOnly }) => { .where('author', userId) if (publicOnly) query.andWhere('streams.isPublic', true) + if (streamIdWhitelist?.length) query.whereIn('streams.streamId', streamIdWhitelist) return query } @@ -204,11 +205,11 @@ module.exports = { } }, - async getCommitsByUserId({ userId, limit, cursor, publicOnly }) { + async getCommitsByUserId({ userId, limit, cursor, publicOnly, streamIdWhitelist }) { limit = limit || 25 publicOnly = publicOnly !== false - const query = getCommitsByUserIdBase({ userId, publicOnly }) + const query = getCommitsByUserIdBase({ userId, publicOnly, streamIdWhitelist }) if (cursor) query.andWhere('commits.createdAt', '<', cursor) @@ -221,8 +222,8 @@ module.exports = { } }, - async getCommitsTotalCountByUserId({ userId, publicOnly }) { - const query = getCommitsByUserIdBase({ userId, publicOnly }) + async getCommitsTotalCountByUserId({ userId, publicOnly, streamIdWhitelist }) { + const query = getCommitsByUserIdBase({ userId, publicOnly, streamIdWhitelist }) query.clearSelect() query.select(knex.raw('COUNT(*) as count')) diff --git a/packages/server/modules/core/services/streams.js b/packages/server/modules/core/services/streams.js index 210b037e1b..deb1da66d8 100644 --- a/packages/server/modules/core/services/streams.js +++ b/packages/server/modules/core/services/streams.js @@ -17,6 +17,10 @@ const { dbLogger } = require('@/logging/logging') const { createStreamReturnRecord } = require('@/modules/core/services/streams/management') +const { isResourceAllowed } = require('@/modules/core/helpers/token') +const { + TokenResourceIdentifierType +} = require('@/modules/core/graph/generated/graphql') /** * NOTE: Stop adding stuff to this service, create specialized service modules instead for various domains @@ -73,7 +77,14 @@ module.exports = { return await deleteStreamFromDb(streamId) }, - async getStreams({ cursor, limit, orderBy, visibility, searchQuery }) { + async getStreams({ + cursor, + limit, + orderBy, + visibility, + searchQuery, + streamIdWhitelist + }) { const query = knex.select().from('streams') const countQuery = Streams.knex() @@ -99,6 +110,12 @@ module.exports = { query.andWhere(publicFunc) countQuery.andWhere(publicFunc) } + + if (streamIdWhitelist?.length) { + query.whereIn('id', streamIdWhitelist) + countQuery.whereIn('id', streamIdWhitelist) + } + const [res] = await countQuery.count() const count = parseInt(res.count) @@ -134,11 +151,18 @@ module.exports = { * @param {string} p.userId * @param {string} p.streamId * @param {boolean} [p.favorited] Whether to favorite or unfavorite (true by default) + * @param {import('@/modules/core/helpers/token').ContextResourceAccessRules} [p.userResourceAccessRules] Resource access rules (if any) for the user doing the favoriting * @returns {Promise} Updated stream */ - async favoriteStream({ userId, streamId, favorited }) { + async favoriteStream({ userId, streamId, favorited, userResourceAccessRules }) { // Check if user has access to stream - if (!(await canUserFavoriteStream({ userId, streamId }))) { + const canFavorite = await canUserFavoriteStream({ userId, streamId }) + const hasResourceAccess = isResourceAllowed({ + resourceId: streamId, + resourceAccessRules: userResourceAccessRules, + resourceType: TokenResourceIdentifierType.Project + }) + if (!canFavorite || !hasResourceAccess) { throw new UnauthorizedError("User doesn't have access to the specified stream", { info: { userId, streamId } }) @@ -157,19 +181,21 @@ module.exports = { * @param {string} p.userId * @param {number} [p.limit] Defaults to 25 * @param {string} [p.cursor] Optionally specify date after which to look for favorites + * @param {string[] | undefined} [p.streamIdWhitelist] Optionally specify a list of stream IDs to filter by * @returns */ - async getFavoriteStreamsCollection({ userId, limit, cursor }) { + async getFavoriteStreamsCollection({ userId, limit, cursor, streamIdWhitelist }) { limit = _.clamp(limit || 25, 1, 25) // Get total count of favorited streams - const totalCount = await getFavoritedStreamsCount(userId) + const totalCount = await getFavoritedStreamsCount(userId, streamIdWhitelist) // Get paginated streams const { cursor: finalCursor, streams } = await getFavoritedStreams({ userId, cursor, - limit + limit, + streamIdWhitelist }) return { totalCount, cursor: finalCursor, items: streams } diff --git a/packages/server/modules/core/services/streams/discoverableStreams.ts b/packages/server/modules/core/services/streams/discoverableStreams.ts index 215d9b8672..26d155157b 100644 --- a/packages/server/modules/core/services/streams/discoverableStreams.ts +++ b/packages/server/modules/core/services/streams/discoverableStreams.ts @@ -11,7 +11,7 @@ import { getDiscoverableStreams as getDiscoverableStreamsQuery, encodeDiscoverableStreamsCursor } from '@/modules/core/repositories/streams' -import { Nullable } from '@/modules/shared/helpers/typeHelper' +import { Nullable, Optional } from '@/modules/shared/helpers/typeHelper' import { clamp } from 'lodash' type StreamCollection = { @@ -32,12 +32,14 @@ function buildRetrievalSortingParams( } function formatRetrievalParams( - args: QueryDiscoverableStreamsArgs + args: QueryDiscoverableStreamsArgs, + streamIdWhitelist?: Optional ): GetDiscoverableStreamsParams { return { sort: buildRetrievalSortingParams(args), cursor: args.cursor || null, - limit: clamp(args.limit || 25, 1, 100) + limit: clamp(args.limit || 25, 1, 100), + streamIdWhitelist } } @@ -45,12 +47,13 @@ function formatRetrievalParams( * Retrieve discoverable streams */ export async function getDiscoverableStreams( - args: QueryDiscoverableStreamsArgs + args: QueryDiscoverableStreamsArgs, + streamIdWhitelist?: Optional ): Promise { - const params = formatRetrievalParams(args) + const params = formatRetrievalParams(args, streamIdWhitelist) const [items, totalCount] = await Promise.all([ getDiscoverableStreamsQuery(params), - countDiscoverableStreams() + countDiscoverableStreams(params) ]) const cursor = encodeDiscoverableStreamsCursor(params.sort.type, items, params.cursor) diff --git a/packages/server/modules/core/services/streams/management.ts b/packages/server/modules/core/services/streams/management.ts index 53408d2c41..1cada4dddf 100644 --- a/packages/server/modules/core/services/streams/management.ts +++ b/packages/server/modules/core/services/streams/management.ts @@ -1,4 +1,4 @@ -import { Roles, wait } from '@speckle/shared' +import { MaybeNullOrUndefined, Roles, wait } from '@speckle/shared' import { addStreamCreatedActivity, addStreamDeletedActivity, @@ -11,7 +11,9 @@ import { StreamCreateInput, StreamRevokePermissionInput, StreamUpdateInput, - StreamUpdatePermissionInput + StreamUpdatePermissionInput, + TokenResourceIdentifier, + TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql' import { StreamRecord } from '@/modules/core/helpers/types' import { @@ -22,7 +24,10 @@ import { } from '@/modules/core/repositories/streams' import { createBranch } from '@/modules/core/services/branches' import { inviteUsersToStream } from '@/modules/serverinvites/services/inviteCreationService' -import { StreamUpdateError } from '@/modules/core/errors/stream' +import { + StreamInvalidAccessError, + StreamUpdateError +} from '@/modules/core/errors/stream' import { isProjectCreateInput } from '@/modules/core/helpers/stream' import { has } from 'lodash' import { @@ -31,14 +36,32 @@ import { removeStreamCollaborator } from '@/modules/core/services/streams/streamAccessService' import { deleteAllStreamInvites } from '@/modules/serverinvites/repositories' +import { + ContextResourceAccessRules, + isNewResourceAllowed +} from '@/modules/core/helpers/token' +import { authorizeResolver } from '@/modules/shared' export async function createStreamReturnRecord( - params: (StreamCreateInput | ProjectCreateInput) & { ownerId: string }, + params: (StreamCreateInput | ProjectCreateInput) & { + ownerId: string + ownerResourceAccessRules?: MaybeNullOrUndefined + }, options?: Partial<{ createActivity: boolean }> ): Promise { - const { ownerId } = params + const { ownerId, ownerResourceAccessRules } = params const { createActivity = true } = options || {} + const canCreateStream = isNewResourceAllowed({ + resourceType: TokenResourceIdentifierType.Project, + resourceAccessRules: ownerResourceAccessRules + }) + if (!canCreateStream) { + throw new StreamInvalidAccessError( + 'You do not have the permissions to create a new stream' + ) + } + const stream = await createStream(params, { ownerId }) const streamId = stream.id @@ -52,7 +75,12 @@ export async function createStreamReturnRecord( // Invite contributors? if (!isProjectCreateInput(params) && params.withContributors?.length) { - await inviteUsersToStream(ownerId, streamId, params.withContributors) + await inviteUsersToStream( + ownerId, + streamId, + params.withContributors, + ownerResourceAccessRules + ) } // Save activity @@ -73,7 +101,25 @@ export async function createStreamReturnRecord( * @param {string} streamId * @param {string} deleterId */ -export async function deleteStreamAndNotify(streamId: string, deleterId: string) { +export async function deleteStreamAndNotify( + streamId: string, + deleterId: string, + deleterResourceAccessRules: ContextResourceAccessRules, + options?: { + skipAccessChecks?: boolean + } +) { + const { skipAccessChecks = false } = options || {} + + if (!skipAccessChecks) { + await authorizeResolver( + deleterId, + streamId, + Roles.Stream.Owner, + deleterResourceAccessRules + ) + } + await addStreamDeletedActivity({ streamId, deleterId }) // TODO: this has been around since before my time, we should get rid of it... @@ -90,8 +136,16 @@ export async function deleteStreamAndNotify(streamId: string, deleterId: string) */ export async function updateStreamAndNotify( update: StreamUpdateInput | ProjectUpdateInput, - updaterId: string + updaterId: string, + updaterResourceAccessRules: ContextResourceAccessRules ) { + await authorizeResolver( + updaterId, + update.id, + Roles.Stream.Owner, + updaterResourceAccessRules + ) + const oldStream = await getStream({ streamId: update.id, userId: updaterId }) if (!oldStream) { throw new StreamUpdateError('Stream not found', { @@ -129,7 +183,8 @@ const isStreamRevokePermissionInput = ( export async function updateStreamRoleAndNotify( update: PermissionUpdateInput, - updaterId: string + updaterId: string, + updaterResourceAccessRules: MaybeNullOrUndefined ) { const smallestStreamRole = Roles.Stream.Reviewer const params = { @@ -164,9 +219,15 @@ export async function updateStreamRoleAndNotify( params.streamId, params.userId, params.role, - updaterId + updaterId, + updaterResourceAccessRules ) } else { - return await removeStreamCollaborator(params.streamId, params.userId, updaterId) + return await removeStreamCollaborator( + params.streamId, + params.userId, + updaterId, + updaterResourceAccessRules + ) } } diff --git a/packages/server/modules/core/services/streams/onboarding.ts b/packages/server/modules/core/services/streams/onboarding.ts index 2c6c5a4739..afaf035e2e 100644 --- a/packages/server/modules/core/services/streams/onboarding.ts +++ b/packages/server/modules/core/services/streams/onboarding.ts @@ -1,5 +1,8 @@ import { Optional } from '@speckle/shared' -import { StreamCloneError } from '@/modules/core/errors/stream' +import { + StreamCloneError, + StreamInvalidAccessError +} from '@/modules/core/errors/stream' import { cloneStream } from '@/modules/core/services/streams/clone' import { StreamRecord } from '@/modules/core/helpers/types' import { logger } from '@/logging/logging' @@ -7,8 +10,26 @@ import { createStreamReturnRecord } from '@/modules/core/services/streams/manage import { getOnboardingBaseProject } from '@/modules/cross-server-sync/services/onboardingProject' import { updateStream } from '../../repositories/streams' import { getUser } from '../users' +import { + ContextResourceAccessRules, + isNewResourceAllowed +} from '@/modules/core/helpers/token' +import { TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql' + +export async function createOnboardingStream( + targetUserId: string, + targetUserResourceAccessRules: ContextResourceAccessRules +) { + const canCreateStream = isNewResourceAllowed({ + resourceType: TokenResourceIdentifierType.Project, + resourceAccessRules: targetUserResourceAccessRules + }) + if (!canCreateStream) { + throw new StreamInvalidAccessError( + 'You do not have the permissions to create a new stream' + ) + } -export async function createOnboardingStream(targetUserId: string) { const sourceStream = await getOnboardingBaseProject() // clone from base let newStream: Optional = undefined @@ -29,7 +50,10 @@ export async function createOnboardingStream(targetUserId: string) { // clone failed, just create empty stream if (!newStream) { logger.warn('Fallback: Creating a blank stream for onboarding') - newStream = await createStreamReturnRecord({ ownerId: targetUserId }) + newStream = await createStreamReturnRecord({ + ownerId: targetUserId, + ownerResourceAccessRules: targetUserResourceAccessRules + }) } logger.info('Updating onboarding stream title') diff --git a/packages/server/modules/core/services/streams/streamAccessService.js b/packages/server/modules/core/services/streams/streamAccessService.js index 994349a77e..9f1615733f 100644 --- a/packages/server/modules/core/services/streams/streamAccessService.js +++ b/packages/server/modules/core/services/streams/streamAccessService.js @@ -37,9 +37,15 @@ async function isStreamCollaborator(userId, streamId) { * @param {string} [userId] If falsy, will throw for non-public streams * @param {string} streamId * @param {string} [expectedRole] Defaults to reviewer + * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [userResourceAccessLimits] * @returns {Promise} */ -async function validateStreamAccess(userId, streamId, expectedRole) { +async function validateStreamAccess( + userId, + streamId, + expectedRole, + userResourceAccessLimits +) { expectedRole = expectedRole || Roles.Stream.Reviewer const streamRoles = Object.values(Roles.Stream) @@ -50,7 +56,7 @@ async function validateStreamAccess(userId, streamId, expectedRole) { userId = userId || null try { - await authorizeResolver(userId, streamId, expectedRole) + await authorizeResolver(userId, streamId, expectedRole, userResourceAccessLimits) } catch (e) { if (e instanceof ForbiddenError) { throw new StreamInvalidAccessError( @@ -77,11 +83,22 @@ async function validateStreamAccess(userId, streamId, expectedRole) { * @param {string} streamId * @param {string} userId ID of user that should be removed * @param {string} removedById ID of user that is doing the removing + * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [removerResourceAccessRules] Resource access rules (if any) for the user doing the removing */ -async function removeStreamCollaborator(streamId, userId, removedById) { +async function removeStreamCollaborator( + streamId, + userId, + removedById, + removerResourceAccessRules +) { if (userId !== removedById) { // User must be a stream owner to remove others - await validateStreamAccess(removedById, streamId, Roles.Stream.Owner) + await validateStreamAccess( + removedById, + streamId, + Roles.Stream.Owner, + removerResourceAccessRules + ) } else { // User must have any kind of role to remove himself await isStreamCollaborator(userId, streamId) @@ -107,6 +124,7 @@ async function removeStreamCollaborator(streamId, userId, removedById) { * @param {string} userId ID of user who is being added * @param {string} role * @param {string} addedById ID of user who is adding the new collaborator + * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [adderResourceAccessRules] Resource access rules (if any) for the user doing the adding * @param {{ * fromInvite?: boolean, * }} param4 @@ -116,6 +134,7 @@ async function addOrUpdateStreamCollaborator( userId, role, addedById, + adderResourceAccessRules, { fromInvite } = {} ) { const validRoles = Object.values(Roles.Stream) @@ -129,7 +148,12 @@ async function addOrUpdateStreamCollaborator( ) } - await validateStreamAccess(addedById, streamId, Roles.Stream.Owner) + await validateStreamAccess( + addedById, + streamId, + Roles.Stream.Owner, + adderResourceAccessRules + ) // make sure server guests cannot be stream owners if (role === Roles.Stream.Owner) { diff --git a/packages/server/modules/core/services/tokens.ts b/packages/server/modules/core/services/tokens.ts index 31d703559b..1d4c805a54 100644 --- a/packages/server/modules/core/services/tokens.ts +++ b/packages/server/modules/core/services/tokens.ts @@ -6,11 +6,16 @@ import { ApiTokens, PersonalApiTokens, TokenScopes, - UserServerAppTokens + UserServerAppTokens, + TokenResourceAccess } from '@/modules/core/dbSchema' -import { TokenValidationResult } from '@/modules/core/helpers/types' +import { + TokenResourceAccessRecord, + TokenValidationResult +} from '@/modules/core/helpers/types' import { getTokenAppInfo } from '@/modules/core/repositories/tokens' -import { ServerRoles } from '@speckle/shared' +import { Optional, ServerRoles } from '@speckle/shared' +import { TokenResourceIdentifierInput } from '@/modules/core/graph/generated/graphql' /* Tokens @@ -31,12 +36,17 @@ export async function createToken({ userId, name, scopes, - lifespan + lifespan, + limitResources }: { userId: string name: string scopes: string[] lifespan?: number | bigint + /** + * Optionally limit the resources that the token can access + */ + limitResources?: TokenResourceIdentifierInput[] | null }) { const { tokenId, tokenString, tokenHash, lastChars } = await createBareToken() @@ -51,9 +61,20 @@ export async function createToken({ lifespan } const tokenScopes = scopes.map((scope) => ({ tokenId, scopeName: scope })) + const resourceAccessEntries: Optional = + limitResources?.map((resource) => ({ + tokenId, + resourceId: resource.id, + resourceType: resource.type + })) await ApiTokens.knex().insert(token) - await TokenScopes.knex().insert(tokenScopes) + await Promise.all([ + TokenScopes.knex().insert(tokenScopes), + ...(resourceAccessEntries?.length + ? [TokenResourceAccess.knex().insert(resourceAccessEntries)] + : []) + ]) return { id: tokenId, token: tokenId + tokenString } } @@ -111,7 +132,7 @@ export async function validateToken( const valid = await bcrypt.compare(tokenContent, token.tokenDigest) if (valid) { - const [scopes, acl, app] = await Promise.all([ + const [scopes, acl, app, resourceAccessRules] = await Promise.all([ TokenScopes.knex() .select<{ scopeName: string }[]>('scopeName') .where({ tokenId }), @@ -120,6 +141,9 @@ export async function validateToken( .where({ userId: token.owner }) .first(), getTokenAppInfo({ token: tokenString }), + TokenResourceAccess.knex().where({ + [TokenResourceAccess.col.tokenId]: tokenId + }), ApiTokens.knex().where({ id: tokenId }).update({ lastUsed: knex.fn.now() }) ]) const role = acl!.role @@ -129,7 +153,8 @@ export async function validateToken( userId: token.owner, role, scopes: scopes.map((s) => s.scopeName), - appId: app?.id || null + appId: app?.id || null, + resourceAccessRules: resourceAccessRules.length ? resourceAccessRules : null } } else return { valid: false } } diff --git a/packages/server/modules/core/tests/apitokens.spec.ts b/packages/server/modules/core/tests/apitokens.spec.ts index 690d32ba6a..3bd0439a55 100644 --- a/packages/server/modules/core/tests/apitokens.spec.ts +++ b/packages/server/modules/core/tests/apitokens.spec.ts @@ -1,9 +1,14 @@ import { createApp } from '@/modules/auth/services/apps' +import { TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql' import { createAppToken } from '@/modules/core/services/tokens' import { BasicTestUser, createTestUsers } from '@/test/authHelper' import { + AdminProjectListDocument, AppTokenCreateDocument, CreateTokenDocument, + GetUserStreamsDocument, + ReadStreamDocument, + ReadStreamsDocument, RevokeTokenDocument, TokenAppInfoDocument } from '@/test/graphql/generated/graphql' @@ -13,16 +18,19 @@ import { testApolloServer } from '@/test/graphqlHelper' import { beforeEachContext } from '@/test/hooks' +import { BasicTestStream, createTestStreams } from '@/test/speckle-helpers/streamHelper' import { AllScopes, Roles, Scopes } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' import { difference } from 'lodash' +import type { Express } from 'express' +import request from 'supertest' /** * Older API token test cases can be found in `graph.spec.js` */ describe('API Tokens', () => { - const userOne: BasicTestUser = { + const user1: BasicTestUser = { name: 'Dimitrie Stefanescu', email: 'didimitrie@gmail.com', password: 'sn3aky-1337-b1m', @@ -30,20 +38,22 @@ describe('API Tokens', () => { } let apollo: TestApolloServer + let app: Express before(async () => { - await beforeEachContext() - await createTestUsers([userOne]) + const ctx = await beforeEachContext() + await createTestUsers([user1]) apollo = await testApolloServer({ context: createTestContext({ auth: true, - userId: userOne.id, + userId: user1.id, role: Roles.Server.Admin, token: 'asd', scopes: AllScopes }) }) + app = ctx.app }) it("don't show an associated app, if they actually don't have one", async () => { @@ -196,7 +206,7 @@ describe('API Tokens', () => { const appToken = await createAppToken({ appId: testApp1Id, - userId: userOne.id, + userId: user1.id, name: 'testapp', scopes: AllScopes }) @@ -205,7 +215,7 @@ describe('API Tokens', () => { apollo = await testApolloServer({ context: createTestContext({ auth: true, - userId: userOne.id, + userId: user1.id, role: Roles.Server.Admin, scopes: AllScopes, token: testApp1Token, @@ -279,5 +289,321 @@ describe('API Tokens', () => { ) ).to.be.ok }) + + it('can create app token with limited resource rules', async () => { + const { data, errors } = await apollo.execute(AppTokenCreateDocument, { + token: { + name: 'test2', + scopes: [Scopes.Profile.Read], + limitResources: [{ id: 'abcde', type: TokenResourceIdentifierType.Project }] + } + }) + + expect(data?.appTokenCreate).to.be.ok + expect(errors).to.not.be.ok + }) + + describe('with limited resource access', () => { + const user2: BasicTestUser = { + name: 'Some other guy', + email: 'bababooey@gmail.com', + password: 'sn3aky-1337-b1m', + id: '' + } + + const stream1: BasicTestStream = { + name: 'user1 stream 1', + isPublic: true, + ownerId: user1.id, + id: '' + } + const stream2: BasicTestStream = { + name: 'user1 stream 2', + isPublic: false, + ownerId: user1.id, + id: '' + } + const stream3: BasicTestStream = { + name: 'user2 stream 1', + isPublic: true, + ownerId: user2.id, + id: '' + } + const stream4: BasicTestStream = { + name: 'user2 stream 2', + isPublic: true, + ownerId: user2.id, + id: '' + } + + let limitedToken1: string + + before(async () => { + await createTestUsers([user2]) + await createTestStreams([ + [stream1, user1], + [stream2, user1], + [stream3, user2], + [stream4, user2] + ]) + + // Create token + const limitResources = [ + { id: stream1.id, type: TokenResourceIdentifierType.Project }, + { id: stream3.id, type: TokenResourceIdentifierType.Project } + ] + const { data } = await apollo.execute(AppTokenCreateDocument, { + token: { + name: 'test2', + scopes: [Scopes.Profile.Read, Scopes.Streams.Read, Scopes.Streams.Write], + limitResources + } + }) + limitedToken1 = data?.appTokenCreate || '' + if (!limitedToken1.length) { + throw new Error("Couldn't prepare token for test") + } + + apollo = await testApolloServer({ + context: createTestContext({ + auth: true, + userId: user1.id, + role: Roles.Server.Admin, + scopes: AllScopes, + token: limitedToken1, + appId: testApp1Id, + resourceAccessRules: limitResources + }) + }) + }) + + it("can't create new token with rules that are relaxed from the original token", async () => { + const res1 = await apollo.execute(AppTokenCreateDocument, { + token: { + name: 'test2', + scopes: [Scopes.Profile.Read], + limitResources: null + } + }) + const res2 = await apollo.execute(AppTokenCreateDocument, { + token: { + name: 'test2', + scopes: [Scopes.Profile.Read], + limitResources: [ + { id: stream1.id, type: TokenResourceIdentifierType.Project }, + { id: stream2.id, type: TokenResourceIdentifierType.Project } + ] + } + }) + + const responses = [res1, res2] + for (const res of responses) { + expect(res.data?.appTokenCreate).to.not.be.ok + expect( + (res.errors || []).find((e) => + e.message.includes( + "You can't create a token with access to resources that you don't currently have access to" + ) + ) + ).to.be.ok + } + }) + + it('can only access allowed stream through stream()', async () => { + const stream1Res = await apollo.execute(ReadStreamDocument, { id: stream1.id }) + const stream2Res = await apollo.execute(ReadStreamDocument, { id: stream2.id }) + const stream2NoRulesRes = await apollo.execute( + ReadStreamDocument, + { id: stream2.id }, + { context: { resourceAccessRules: null, token: 'somefaketoken' } } + ) + + expect(stream1Res.data?.stream?.id).to.be.ok + expect(stream1Res.errors).to.not.be.ok + + expect(stream2Res.data?.stream).to.not.be.ok + expect( + (stream2Res.errors || []).find((e) => + e.message.includes('You do not have access to this resource') + ) + ).to.be.ok + + expect(stream2NoRulesRes.data?.stream?.id).to.be.ok + expect(stream2NoRulesRes.errors).to.not.be.ok + }) + + it('can only access allowed streams through streams()', async () => { + const { data, errors } = await apollo.execute(ReadStreamsDocument, {}) + + expect(errors).to.be.not.ok + expect(data?.streams).to.be.ok + expect(data?.streams?.totalCount).to.equal(1) + expect(data?.streams?.items?.length).to.equal(1) + expect(data?.streams?.items?.[0].id).to.equal(stream1.id) + }) + + it('can only access allowed streams through User.streams', async () => { + const { data, errors } = await apollo.execute(GetUserStreamsDocument, { + userId: user2.id + }) + + expect(errors).to.be.not.ok + expect(data?.user?.streams).to.be.ok + expect(data?.user?.streams?.totalCount).to.equal(1) + expect(data?.user?.streams?.items?.length).to.equal(1) + expect(data?.user?.streams?.items?.[0].id).to.equal(stream3.id) + }) + + it('can only access allowed projects through admin.projectList', async () => { + const { data, errors } = await apollo.execute(AdminProjectListDocument, {}) + + expect(errors).to.be.not.ok + expect(data?.admin?.projectList?.totalCount).to.equal(2) + expect(data?.admin?.projectList?.items?.length).to.equal(2) + + const returnedIds = data?.admin?.projectList?.items?.map((p) => p.id) || [] + expect(returnedIds).to.deep.equalInAnyOrder([stream1.id, stream3.id]) + }) + + it('can only post to /objects/:streamId for allowed streams', async () => { + const resAllowed = await request(app) + .post(`/objects/${stream1.id}`) + .set('Authorization', `Bearer ${limitedToken1}`) + .send({ fake: 'data' }) + + // We sent an invalid payload so 400 is fine, as long as its not a 401 + expect(resAllowed).to.have.status(400) + + const resDisallowed = await request(app) + .post(`/objects/${stream2.id}`) + .set('Authorization', `Bearer ${limitedToken1}`) + .send({ fake: 'data' }) + expect(resDisallowed).to.have.status(401) + }) + + it('can only GET /objects/:streamId/:objectId for allowed streams', async () => { + const resAllowed = await request(app) + .get(`/objects/${stream1.id}/fakeobjectid`) + .set('Authorization', `Bearer ${limitedToken1}`) + expect(resAllowed).to.have.status(404) + + const resDisallowed = await request(app) + .get(`/objects/${stream2.id}/fakeobjectid`) + .set('Authorization', `Bearer ${limitedToken1}`) + expect(resDisallowed).to.have.status(401) + }) + + it('can only GET /objects/:streamId/:objectId/single for allowed streams', async () => { + const resAllowed = await request(app) + .get(`/objects/${stream1.id}/fakeobjectid/single`) + .set('Authorization', `Bearer ${limitedToken1}`) + expect(resAllowed).to.have.status(404) + + const resDisallowed = await request(app) + .get(`/objects/${stream2.id}/fakeobjectid/single`) + .set('Authorization', `Bearer ${limitedToken1}`) + expect(resDisallowed).to.have.status(401) + }) + + it('can only POST /api/getobjects/:streamId for allowed streams', async () => { + const resAllowed = await request(app) + .post(`/api/getobjects/${stream1.id}`) + .set('Authorization', `Bearer ${limitedToken1}`) + .send({ fake: 'data' }) + + // We sent an invalid payload so 500 is fine, as long as its not a 401 + expect(resAllowed).to.have.status(500) + + const resDisallowed = await request(app) + .post(`/api/getobjects/${stream2.id}`) + .set('Authorization', `Bearer ${limitedToken1}`) + .send({ fake: 'data' }) + expect(resDisallowed).to.have.status(401) + }) + + it('can only POST /api/diff/:streamId for allowed streams', async () => { + const resAllowed = await request(app) + .post(`/api/diff/${stream1.id}`) + .set('Authorization', `Bearer ${limitedToken1}`) + .send({ fake: 'data' }) + + // We sent an invalid payload so 500 is fine, as long as its not a 401 + expect(resAllowed).to.have.status(500) + + const resDisallowed = await request(app) + .post(`/api/diff/${stream2.id}`) + .set('Authorization', `Bearer ${limitedToken1}`) + .send({ fake: 'data' }) + expect(resDisallowed).to.have.status(401) + }) + + it('can only POST /api/stream/:streamId/blob for allowed streams', async () => { + const resAllowed = await request(app) + .post(`/api/stream/${stream1.id}/blob`) + .set('Authorization', `Bearer ${limitedToken1}`) + .send({ fake: 'data' }) + + // We sent an invalid payload so 500 is fine, as long as its not a 403 + expect(resAllowed).to.have.status(500) + + const resDisallowed = await request(app) + .post(`/api/stream/${stream2.id}/blob`) + .set('Authorization', `Bearer ${limitedToken1}`) + .send({ fake: 'data' }) + expect(resDisallowed).to.have.status(403) + }) + + it('can only POST /api/stream/:streamId/blob/diff for allowed streams', async () => { + const resAllowed = await request(app) + .post(`/api/stream/${stream1.id}/blob/diff`) + .set('Authorization', `Bearer ${limitedToken1}`) + .send({ fake: 'data' }) + + // We sent an invalid payload so 400 is fine, as long as its not a 403 + expect(resAllowed).to.have.status(400) + + const resDisallowed = await request(app) + .post(`/api/stream/${stream2.id}/blob/diff`) + .set('Authorization', `Bearer ${limitedToken1}`) + .send([1, 2, 3]) + expect(resDisallowed).to.have.status(403) + }) + + it('can only GET /api/stream/:streamId/blob/:blobId for allowed streams', async () => { + const resAllowed = await request(app) + .get(`/api/stream/${stream1.id}/blob/fakeblobid`) + .set('Authorization', `Bearer ${limitedToken1}`) + expect(resAllowed).to.have.status(404) + + const resDisallowed = await request(app) + .get(`/api/stream/${stream2.id}/blob/fakeblobid`) + .set('Authorization', `Bearer ${limitedToken1}`) + expect(resDisallowed).to.have.status(403) + }) + + it('can only DELETE /api/stream/:streamId/blob/:blobId for allowed streams', async () => { + const resAllowed = await request(app) + .delete(`/api/stream/${stream1.id}/blob/fakeblobid`) + .set('Authorization', `Bearer ${limitedToken1}`) + expect(resAllowed).to.have.status(404) + + const resDisallowed = await request(app) + .delete(`/api/stream/${stream2.id}/blob/fakeblobid`) + .set('Authorization', `Bearer ${limitedToken1}`) + expect(resDisallowed).to.have.status(403) + }) + + it('can only GET /api/stream/:streamId/blobs for allowed streams', async () => { + const resAllowed = await request(app) + .get(`/api/stream/${stream1.id}/blobs`) + .set('Authorization', `Bearer ${limitedToken1}`) + expect(resAllowed).to.have.status(200) + + const resDisallowed = await request(app) + .get(`/api/stream/${stream2.id}/blobs`) + .set('Authorization', `Bearer ${limitedToken1}`) + expect(resDisallowed).to.have.status(403) + }) + }) }) }) diff --git a/packages/server/modules/core/tests/graphSubs.spec.js b/packages/server/modules/core/tests/graphSubs.spec.js index ee18e0d247..5e9e2e85cd 100644 --- a/packages/server/modules/core/tests/graphSubs.spec.js +++ b/packages/server/modules/core/tests/graphSubs.spec.js @@ -75,11 +75,15 @@ describe('GraphQL API Subscriptions @gql-subscriptions', () => { addr = `http://127.0.0.1:${childPort}/graphql` wsAddr = `ws://127.0.0.1:${childPort}/graphql` + // if u want to see full child process output, change LOG_LEVEL to info for dev:server:test in package.json serverProcess = childProcess.spawn( /^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['run', 'dev:server:test'], - { cwd: packageRoot, env: { ...process.env, PORT: childPort } } + { cwd: packageRoot, env: { ...process.env, PORT: childPort }, stdio: 'inherit' } ) + serverProcess.on('error', (err) => { + console.error(err) + }) console.log(` Waiting on child server to be started at PORT ${childPort} `) // lets wait for the server is starting up diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index c499283eb5..348c0cbc1f 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -160,6 +160,14 @@ export type AppCreateInput = { termsAndConditionsLink?: InputMaybe; }; +export type AppTokenCreateInput = { + lifespan?: InputMaybe; + /** Optionally limit the token to only have access to specific resources */ + limitResources?: InputMaybe>; + name: Scalars['String']; + scopes: Array; +}; + export type AppUpdateInput = { description: Scalars['String']; id: Scalars['String']; @@ -1064,7 +1072,7 @@ export type MutationAppRevokeAccessArgs = { export type MutationAppTokenCreateArgs = { - token: ApiTokenCreateInput; + token: AppTokenCreateInput; }; @@ -1866,8 +1874,6 @@ export type Query = { * Pass in the `query` parameter to search by name, description or ID. */ streams?: Maybe; - testList: Array; - testNumber?: Maybe; /** * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). * @deprecated To be removed in the near future! Use 'activeUser' to get info about the active user or 'otherUser' to get info about another user. @@ -2546,12 +2552,21 @@ export type SubscriptionViewerUserActivityBroadcastedArgs = { target: ViewerUpdateTrackingTarget; }; -export type TestItem = { - __typename?: 'TestItem'; - bar: Scalars['String']; - foo: Scalars['String']; +export type TokenResourceIdentifier = { + __typename?: 'TokenResourceIdentifier'; + id: Scalars['String']; + type: TokenResourceIdentifierType; +}; + +export type TokenResourceIdentifierInput = { + id: Scalars['String']; + type: TokenResourceIdentifierType; }; +export enum TokenResourceIdentifierType { + Project = 'project' +} + export type UpdateModelInput = { description?: InputMaybe; id: Scalars['ID']; diff --git a/packages/server/modules/fileuploads/graph/resolvers/fileUploads.ts b/packages/server/modules/fileuploads/graph/resolvers/fileUploads.ts index 626f7c3888..e7f9874afe 100644 --- a/packages/server/modules/fileuploads/graph/resolvers/fileUploads.ts +++ b/packages/server/modules/fileuploads/graph/resolvers/fileUploads.ts @@ -49,7 +49,12 @@ export = { const { id: projectId } = args if (payload.projectId !== projectId) return false - await authorizeResolver(ctx.userId, projectId, Roles.Stream.Reviewer) + await authorizeResolver( + ctx.userId, + projectId, + Roles.Stream.Reviewer, + ctx.resourceAccessRules + ) return true } ) @@ -61,7 +66,12 @@ export = { const { id: projectId } = args if (payload.projectId !== projectId) return false - await authorizeResolver(ctx.userId, projectId, Roles.Stream.Reviewer) + await authorizeResolver( + ctx.userId, + projectId, + Roles.Stream.Reviewer, + ctx.resourceAccessRules + ) return true } ) @@ -73,7 +83,12 @@ export = { const { id: projectId } = args if (payload.projectId !== projectId) return false - await authorizeResolver(ctx.userId, projectId, Roles.Stream.Reviewer) + await authorizeResolver( + ctx.userId, + projectId, + Roles.Stream.Reviewer, + ctx.resourceAccessRules + ) return true } ) diff --git a/packages/server/modules/previews/index.js b/packages/server/modules/previews/index.js index c3e256618e..93eb28c2b8 100644 --- a/packages/server/modules/previews/index.js +++ b/packages/server/modules/previews/index.js @@ -154,7 +154,8 @@ exports.init = (app, isInitial) => { await authorizeResolver( req.context.userId, req.params.streamId, - Roles.Stream.Reviewer + Roles.Stream.Reviewer, + req.context.resourceAccessRules ) } catch (err) { return { hasPermissions: false, httpErrorCode: 401 } diff --git a/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts b/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts index a7087550a6..fc8e3e0b17 100644 --- a/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts +++ b/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts @@ -54,19 +54,31 @@ export = { }, Mutation: { async serverInviteCreate(_parent, args, context) { - await createAndSendInvite({ - target: args.input.email, - inviterId: context.userId!, - message: args.input.message, - serverRole: args.input.serverRole - }) + await createAndSendInvite( + { + target: args.input.email, + inviterId: context.userId!, + message: args.input.message, + serverRole: args.input.serverRole + }, + context.resourceAccessRules + ) return true }, async streamInviteCreate(_parent, args, context) { - await authorizeResolver(context.userId, args.input.streamId, Roles.Stream.Owner) - await createStreamInviteAndNotify(args.input, context.userId!) + await authorizeResolver( + context.userId, + args.input.streamId, + Roles.Stream.Owner, + context.resourceAccessRules + ) + await createStreamInviteAndNotify( + args.input, + context.userId!, + context.resourceAccessRules + ) return true }, @@ -79,12 +91,15 @@ export = { for (const paramsBatchArray of batches) { await Promise.all( paramsBatchArray.map((params) => - createAndSendInvite({ - target: params.email, - inviterId: context.userId!, - message: params.message, - serverRole: params.serverRole - }) + createAndSendInvite( + { + target: params.email, + inviterId: context.userId!, + message: params.message, + serverRole: params.serverRole + }, + context.resourceAccessRules + ) ) ) } @@ -112,15 +127,18 @@ export = { paramsBatchArray.map((params) => { const { email, userId, message, streamId, role, serverRole } = params const target = (userId ? buildUserTarget(userId) : email)! - return createAndSendInvite({ - target, - inviterId: context.userId!, - message, - resourceTarget: ResourceTargets.Streams, - resourceId: streamId, - role: role || Roles.Stream.Contributor, - serverRole - }) + return createAndSendInvite( + { + target, + inviterId: context.userId!, + message, + resourceTarget: ResourceTargets.Streams, + resourceId: streamId, + role: role || Roles.Stream.Contributor, + serverRole + }, + context.resourceAccessRules + ) }) ) } @@ -129,15 +147,15 @@ export = { }, async streamInviteUse(_parent, args, ctx) { - await useStreamInviteAndNotify(args, ctx.userId!) + await useStreamInviteAndNotify(args, ctx.userId!, ctx.resourceAccessRules) return true }, async streamInviteCancel(_parent, args, ctx) { const { streamId, inviteId } = args - const { userId } = ctx + const { userId, resourceAccessRules } = ctx - await authorizeResolver(userId, streamId, Roles.Stream.Owner) + await authorizeResolver(userId, streamId, Roles.Stream.Owner, resourceAccessRules) await cancelStreamInvite(streamId, inviteId) return true diff --git a/packages/server/modules/serverinvites/services/inviteCreationService.js b/packages/server/modules/serverinvites/services/inviteCreationService.js index 5f15cbeb90..e4ea259143 100644 --- a/packages/server/modules/serverinvites/services/inviteCreationService.js +++ b/packages/server/modules/serverinvites/services/inviteCreationService.js @@ -62,15 +62,21 @@ function resolveResourceName(params, resource) { * Validate that the inviter has access to the resources he's trying to invite people to * @param {CreateInviteParams} params * @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter + * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits */ -async function validateInviter(params, inviter) { +async function validateInviter(params, inviter, inviterResourceAccessLimits) { const { resourceId, resourceTarget } = params if (!inviter) throw new InviteCreateValidationError('Invalid inviter') if (isServerInvite(params)) return try { if (resourceTarget === ResourceTargets.Streams) { - await authorizeResolver(inviter.id, resourceId, Roles.Stream.Owner) + await authorizeResolver( + inviter.id, + resourceId, + Roles.Stream.Owner, + inviterResourceAccessLimits + ) } else { throw new InviteCreateValidationError('Unexpected resource target type') } @@ -141,13 +147,20 @@ async function validateResource(params, resource, targetUser) { * @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter Inviter, resolved from DB * @param {import('@/modules/core/helpers/userHelper').UserRecord | undefined} targetUser Target user, if one exists in our DB * @param {Object | null} resource Invite resource (stream or null) + * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits */ -async function validateInput(params, inviter, targetUser, resource) { +async function validateInput( + params, + inviter, + targetUser, + resource, + inviterResourceAccessLimits +) { const { message } = params // validate inviter & invitee validateTargetUser(params, targetUser) - await validateInviter(params, inviter) + await validateInviter(params, inviter, inviterResourceAccessLimits) // validate resource await validateResource(params, resource, targetUser) @@ -325,9 +338,10 @@ async function buildEmailContents(invite, inviter, targetUser, resource) { /** * Create and send out an invite * @param {CreateInviteParams} params + * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits * @returns {Promise} The ID of the created invite */ -async function createAndSendInvite(params) { +async function createAndSendInvite(params, inviterResourceAccessLimits) { const { inviterId, resourceTarget, resourceId, role, serverRole } = params let { message, target } = params @@ -343,7 +357,13 @@ async function createAndSendInvite(params) { const { userEmail, userId } = resolveTarget(target) // validate inputs - await validateInput(params, inviter, targetUser, resource) + await validateInput( + params, + inviter, + targetUser, + resource, + inviterResourceAccessLimits + ) // Sanitize msg // TODO: We should just use TipTap here @@ -432,9 +452,15 @@ async function resendInviteEmail(invite) { * @param {string} inviterId * @param {string} streamId * @param {string[]} userIds + * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits * @returns {Promise} */ -async function inviteUsersToStream(inviterId, streamId, userIds) { +async function inviteUsersToStream( + inviterId, + streamId, + userIds, + inviterResourceAccessLimits +) { const users = await getUsers(userIds) if (!users.length) return false @@ -446,7 +472,9 @@ async function inviteUsersToStream(inviterId, streamId, userIds) { role: Roles.Stream.Contributor })) - await Promise.all(inviteParamsArray.map((p) => createAndSendInvite(p))) + await Promise.all( + inviteParamsArray.map((p) => createAndSendInvite(p, inviterResourceAccessLimits)) + ) return true } diff --git a/packages/server/modules/serverinvites/services/inviteProcessingService.js b/packages/server/modules/serverinvites/services/inviteProcessingService.js index 263278f16e..34906798ac 100644 --- a/packages/server/modules/serverinvites/services/inviteProcessingService.js +++ b/packages/server/modules/serverinvites/services/inviteProcessingService.js @@ -113,7 +113,7 @@ async function finalizeStreamInvite(accept, streamId, token, userId) { if (accept) { // Add access for user const { role = Roles.Stream.Contributor, inviterId } = invite - await addOrUpdateStreamCollaborator(streamId, userId, role, inviterId, { + await addOrUpdateStreamCollaborator(streamId, userId, role, inviterId, null, { fromInvite: true }) diff --git a/packages/server/modules/serverinvites/services/management.ts b/packages/server/modules/serverinvites/services/management.ts index 10c86ec2a3..6a4f0a3c1c 100644 --- a/packages/server/modules/serverinvites/services/management.ts +++ b/packages/server/modules/serverinvites/services/management.ts @@ -1,9 +1,11 @@ -import { Roles } from '@speckle/shared' +import { MaybeNullOrUndefined, Roles } from '@speckle/shared' import { MutationStreamInviteUseArgs, ProjectInviteCreateInput, ProjectInviteUseInput, - StreamInviteCreateInput + StreamInviteCreateInput, + TokenResourceIdentifier, + TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql' import { InviteCreateValidationError } from '@/modules/serverinvites/errors' import { @@ -13,6 +15,11 @@ import { import { createAndSendInvite } from '@/modules/serverinvites/services/inviteCreationService' import { has } from 'lodash' import { finalizeStreamInvite } from '@/modules/serverinvites/services/inviteProcessingService' +import { + ContextResourceAccessRules, + isResourceAllowed +} from '@/modules/core/helpers/token' +import { StreamInvalidAccessError } from '@/modules/core/errors/stream' type FullProjectInviteCreateInput = ProjectInviteCreateInput & { projectId: string } @@ -22,7 +29,8 @@ const isStreamInviteCreateInput = ( export async function createStreamInviteAndNotify( input: StreamInviteCreateInput | FullProjectInviteCreateInput, - inviterId: string + inviterId: string, + inviterResourceAccessRules: MaybeNullOrUndefined ) { const { email, userId, role } = input @@ -31,15 +39,20 @@ export async function createStreamInviteAndNotify( } const target = (userId ? buildUserTarget(userId) : email)! - await createAndSendInvite({ - target, - inviterId, - resourceTarget: ResourceTargets.Streams, - resourceId: isStreamInviteCreateInput(input) ? input.streamId : input.projectId, - role: role || Roles.Stream.Contributor, - message: isStreamInviteCreateInput(input) ? input.message || undefined : undefined, - serverRole: input.serverRole || undefined - }) + await createAndSendInvite( + { + target, + inviterId, + resourceTarget: ResourceTargets.Streams, + resourceId: isStreamInviteCreateInput(input) ? input.streamId : input.projectId, + role: role || Roles.Stream.Contributor, + message: isStreamInviteCreateInput(input) + ? input.message || undefined + : undefined, + serverRole: input.serverRole || undefined + }, + inviterResourceAccessRules + ) } const isStreamInviteUseArgs = ( @@ -48,9 +61,30 @@ const isStreamInviteUseArgs = ( export async function useStreamInviteAndNotify( input: MutationStreamInviteUseArgs | ProjectInviteUseInput, - userId: string + userId: string, + userResourceAccessRules: ContextResourceAccessRules ) { const { accept, token } = input + + if ( + !isResourceAllowed({ + resourceId: isStreamInviteUseArgs(input) ? input.streamId : input.projectId, + resourceType: TokenResourceIdentifierType.Project, + resourceAccessRules: userResourceAccessRules + }) + ) { + throw new StreamInvalidAccessError( + 'You are not allowed to process an invite for this stream', + { + info: { + userId, + userResourceAccessRules, + input + } + } + ) + } + await finalizeStreamInvite( accept, isStreamInviteUseArgs(input) ? input.streamId : input.projectId, diff --git a/packages/server/modules/shared/authz.ts b/packages/server/modules/shared/authz.ts index 1449784a43..779a1afe91 100644 --- a/packages/server/modules/shared/authz.ts +++ b/packages/server/modules/shared/authz.ts @@ -15,6 +15,12 @@ import { BadRequestError } from '@/modules/shared/errors' import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' +import { Nullable } from '@speckle/shared' +import { + TokenResourceIdentifier, + TokenResourceIdentifierType +} from '@/modules/core/graph/generated/graphql' +import { isResourceAllowed } from '@/modules/core/helpers/token' interface AuthResult { authorized: boolean @@ -27,6 +33,7 @@ interface AuthFailedResult extends AuthResult { } interface Stream { + id: string role?: StreamRoles isPublic: boolean allowPublicComments: boolean @@ -44,6 +51,10 @@ export interface AuthContext { * Set if authenticated with an app token */ appId?: string | null + /** + * Set, if the token has resource access limits (e.g. only access to specific projects) + */ + resourceAccessRules?: Nullable } export interface AuthParams { @@ -158,6 +169,37 @@ export const validateStreamRole = ({ requiredRole }: { requiredRole: StreamRoles roleGetter: (context) => context?.stream?.role || null }) +export const validateResourceAccess: AuthPipelineFunction = async ({ + context, + authResult +}) => { + const { resourceAccessRules } = context + + if (authHasFailed(authResult)) return { context, authResult } + if (!resourceAccessRules?.length) return authSuccess(context) + + const streamId = context.stream?.id + if (!streamId) { + return authSuccess(context) + } + + const hasAccess = isResourceAllowed({ + resourceId: streamId, + resourceType: TokenResourceIdentifierType.Project, + resourceAccessRules + }) + + if (!hasAccess) { + return authFailed( + context, + new ForbiddenError('You do not have the required privileges.'), + true + ) + } + + return authSuccess(context) +} + export const validateScope = ({ requiredScope }: { requiredScope: string }): AuthPipelineFunction => async ({ context, authResult }) => { @@ -263,17 +305,19 @@ export const authPipelineCreator = ( return pipeline } -export const streamWritePermissions = [ +export const streamWritePermissions: AuthPipelineFunction[] = [ validateServerRole({ requiredRole: Roles.Server.Guest }), validateScope({ requiredScope: Scopes.Streams.Write }), contextRequiresStream(getStream as StreamGetter), - validateStreamRole({ requiredRole: Roles.Stream.Contributor }) + validateStreamRole({ requiredRole: Roles.Stream.Contributor }), + validateResourceAccess ] -export const streamReadPermissions = [ +export const streamReadPermissions: AuthPipelineFunction[] = [ validateServerRole({ requiredRole: Roles.Server.Guest }), validateScope({ requiredScope: Scopes.Streams.Read }), contextRequiresStream(getStream as StreamGetter), - validateStreamRole({ requiredRole: Roles.Stream.Contributor }) + validateStreamRole({ requiredRole: Roles.Stream.Contributor }), + validateResourceAccess ] if (adminOverrideEnabled()) streamReadPermissions.push(allowForServerAdmins) diff --git a/packages/server/modules/shared/index.js b/packages/server/modules/shared/index.js index dfda2a8ba2..217639b968 100644 --- a/packages/server/modules/shared/index.js +++ b/packages/server/modules/shared/index.js @@ -12,6 +12,11 @@ const { adminOverrideEnabled } = require('@/modules/shared/helpers/envHelper') const { ServerAcl: ServerAclSchema } = require('@/modules/core/dbSchema') const { getRoles } = require('@/modules/shared/roles') +const { + roleResourceTypeToTokenResourceType, + isResourceAllowed +} = require('@/modules/core/helpers/token') + const ServerAcl = () => ServerAclSchema.knex() /** @@ -31,18 +36,34 @@ async function validateScopes(scopes, scope) { * @param {string | null | undefined} userId * @param {string} resourceId * @param {string} requiredRole + * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [userResourceAccessLimits] */ -async function authorizeResolver(userId, resourceId, requiredRole) { +async function authorizeResolver( + userId, + resourceId, + requiredRole, + userResourceAccessLimits +) { userId = userId || null - 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. const role = roles.find((r) => r.name === requiredRole) - if (!role) throw new ApolloError('Unknown role: ' + requiredRole) + const resourceRuleType = roleResourceTypeToTokenResourceType(role.resourceTarget) + const isResourceLimited = + resourceRuleType && + !isResourceAllowed({ + resourceId, + resourceType: resourceRuleType, + resourceAccessRules: userResourceAccessLimits + }) + if (isResourceLimited) { + throw new ForbiddenError('You do not have access to this resource.') + } + if (adminOverrideEnabled()) { const serverRoles = await ServerAcl().select('role').where({ userId }) if (serverRoles.map((r) => r.role).includes(Roles.Server.Admin)) return requiredRole @@ -64,8 +85,9 @@ async function authorizeResolver(userId, resourceId, requiredRole) { ? await knex(role.aclTableName).select('*').where({ resourceId, userId }).first() : null - if (!userAclEntry) + if (!userAclEntry) { throw new ForbiddenError('You do not have access to this resource.') + } userAclEntry.role = roles.find((r) => r.name === userAclEntry.role) diff --git a/packages/server/modules/shared/middleware/index.ts b/packages/server/modules/shared/middleware/index.ts index 9a799a131c..e9164d69b6 100644 --- a/packages/server/modules/shared/middleware/index.ts +++ b/packages/server/modules/shared/middleware/index.ts @@ -24,6 +24,7 @@ import { pino } from 'pino' import { getIpFromRequest } from '@/modules/shared/utils/ip' import { Netmask } from 'netmask' import { Merge } from 'type-fest' +import { resourceAccessRuleToIdentifier } from '@/modules/core/helpers/token' export const authMiddlewareCreator = (steps: AuthPipelineFunction[]) => { const pipeline = authPipelineCreator(steps) @@ -76,9 +77,19 @@ export async function createAuthContextFromToken( if (!tokenValidationResult.valid) return { auth: false, err: new ForbiddenError('Your token is not valid.') } - const { scopes, userId, role, appId } = tokenValidationResult + const { scopes, userId, role, appId, resourceAccessRules } = tokenValidationResult - return { auth: true, userId, role, token, scopes, appId } + return { + auth: true, + userId, + role, + token, + scopes, + appId, + resourceAccessRules: resourceAccessRules + ? resourceAccessRules.map(resourceAccessRuleToIdentifier) + : null + } } catch (err) { const surelyError = ensureError(err, 'Unknown error during token validation') return { auth: false, err: surelyError } diff --git a/packages/server/modules/shared/test/authz.spec.js b/packages/server/modules/shared/test/authz.spec.js index 829ef0ed02..2f3753e5f6 100644 --- a/packages/server/modules/shared/test/authz.spec.js +++ b/packages/server/modules/shared/test/authz.spec.js @@ -8,7 +8,8 @@ const { contextRequiresStream, allowForAllRegisteredUsersOnPublicStreamsWithPublicComments, allowForRegisteredUsersOnPublicStreamsEvenWithoutRole, - allowForServerAdmins + allowForServerAdmins, + validateResourceAccess } = require('@/modules/shared/authz') const { ForbiddenError: SFE, @@ -18,6 +19,9 @@ const { ContextError } = require('@/modules/shared/errors') const { Roles } = require('@speckle/shared') +const { + TokenResourceIdentifierType +} = require('@/modules/core/graph/generated/graphql') describe('AuthZ @shared', () => { describe('Auth pipeline', () => { @@ -211,6 +215,89 @@ describe('AuthZ @shared', () => { }) }) + describe('Validate resource access', () => { + it('Succeeds when no resource access rules present', async () => { + const res = await validateResourceAccess({ + context: {}, + authResult: {} + }) + + expect(res.authResult.authorized).to.be.true + }) + + it('Succeeds without a stream in the context, even if rules present', async () => { + const res = await validateResourceAccess({ + context: { + resourceAccessRules: [ + { id: 'foo', type: TokenResourceIdentifierType.Project } + ] + }, + authResult: {} + }) + + expect(res.authResult.authorized).to.be.true + }) + + it('Fails if authResult already failed', async () => { + const res = await validateResourceAccess({ + context: { + resourceAccessRules: [ + { id: 'foo', type: TokenResourceIdentifierType.Project } + ] + }, + authResult: { authorized: false, error: new Error('dummy') } + }) + + expect(res.authResult.authorized).to.be.false + }) + + it('Fails if resource access rules arent followed', async () => { + const res = await validateResourceAccess({ + context: { + resourceAccessRules: [ + { id: 'foo', type: TokenResourceIdentifierType.Project } + ], + stream: { id: 'bar' } + }, + authResult: {} + }) + + expect(res.authResult.authorized).to.be.false + expect(res.authResult.error.message).to.equal( + 'You do not have the required privileges.' + ) + }) + + it('Succeeds if resource access rules are followed', async () => { + const res = await validateResourceAccess({ + context: { + resourceAccessRules: [ + { id: 'foo', type: TokenResourceIdentifierType.Project }, + { id: 'bar', type: TokenResourceIdentifierType.Project } + ], + stream: { id: 'bar' } + }, + authResult: {} + }) + + expect(res.authResult.authorized).to.be.true + }) + + it('Success if resource access rules are defined, but are from a different type', async () => { + const res = await validateResourceAccess({ + context: { + resourceAccessRules: [ + { id: 'foo', type: 'fake' }, + { id: 'bar', type: 'fake' } + ] + }, + authResult: {} + }) + + expect(res.authResult.authorized).to.be.true + }) + }) + describe('Context requires stream', () => { const expectAuthError = (expectedError, authResult) => { expect(authResult.authorized).to.be.false diff --git a/packages/server/modules/webhooks/graph/resolvers/webhooks.js b/packages/server/modules/webhooks/graph/resolvers/webhooks.js index ede080fb67..9a08cc7263 100644 --- a/packages/server/modules/webhooks/graph/resolvers/webhooks.js +++ b/packages/server/modules/webhooks/graph/resolvers/webhooks.js @@ -13,7 +13,12 @@ const { const { Roles } = require('@speckle/shared') const streamWebhooksResolver = async (parent, args, context) => { - await authorizeResolver(context.userId, parent.id, Roles.Stream.Owner) + await authorizeResolver( + context.userId, + parent.id, + Roles.Stream.Owner, + context.resourceAccessRules + ) if (args.id) { const wh = await getWebhook({ id: args.id }) @@ -50,7 +55,12 @@ module.exports = { Mutation: { async webhookCreate(parent, args, context) { - await authorizeResolver(context.userId, args.webhook.streamId, Roles.Stream.Owner) + await authorizeResolver( + context.userId, + args.webhook.streamId, + Roles.Stream.Owner, + context.resourceAccessRules + ) const id = await createWebhook({ streamId: args.webhook.streamId, @@ -64,7 +74,12 @@ module.exports = { return id }, async webhookUpdate(parent, args, context) { - await authorizeResolver(context.userId, args.webhook.streamId, Roles.Stream.Owner) + await authorizeResolver( + context.userId, + args.webhook.streamId, + Roles.Stream.Owner, + context.resourceAccessRules + ) const wh = await getWebhook({ id: args.webhook.id }) if (args.webhook.streamId !== wh.streamId) @@ -84,7 +99,12 @@ module.exports = { return !!updated }, async webhookDelete(parent, args, context) { - await authorizeResolver(context.userId, args.webhook.streamId, Roles.Stream.Owner) + await authorizeResolver( + context.userId, + args.webhook.streamId, + Roles.Stream.Owner, + context.resourceAccessRules + ) const wh = await getWebhook({ id: args.webhook.id }) if (args.webhook.streamId !== wh.streamId) diff --git a/packages/server/test/graphql/apiTokens.ts b/packages/server/test/graphql/apiTokens.ts index be92471d42..92235f43ac 100644 --- a/packages/server/test/graphql/apiTokens.ts +++ b/packages/server/test/graphql/apiTokens.ts @@ -22,7 +22,7 @@ export const tokenAppInfoQuery = gql` ` export const appTokenCreateMutation = gql` - mutation AppTokenCreate($token: ApiTokenCreateInput!) { + mutation AppTokenCreate($token: AppTokenCreateInput!) { appTokenCreate(token: $token) } ` diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 1ba35b2591..756c48b069 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -161,6 +161,14 @@ export type AppCreateInput = { termsAndConditionsLink?: InputMaybe; }; +export type AppTokenCreateInput = { + lifespan?: InputMaybe; + /** Optionally limit the token to only have access to specific resources */ + limitResources?: InputMaybe>; + name: Scalars['String']; + scopes: Array; +}; + export type AppUpdateInput = { description: Scalars['String']; id: Scalars['String']; @@ -1065,7 +1073,7 @@ export type MutationAppRevokeAccessArgs = { export type MutationAppTokenCreateArgs = { - token: ApiTokenCreateInput; + token: AppTokenCreateInput; }; @@ -1867,8 +1875,6 @@ export type Query = { * Pass in the `query` parameter to search by name, description or ID. */ streams?: Maybe; - testList: Array; - testNumber?: Maybe; /** * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). * @deprecated To be removed in the near future! Use 'activeUser' to get info about the active user or 'otherUser' to get info about another user. @@ -2547,12 +2553,21 @@ export type SubscriptionViewerUserActivityBroadcastedArgs = { target: ViewerUpdateTrackingTarget; }; -export type TestItem = { - __typename?: 'TestItem'; - bar: Scalars['String']; - foo: Scalars['String']; +export type TokenResourceIdentifier = { + __typename?: 'TokenResourceIdentifier'; + id: Scalars['String']; + type: TokenResourceIdentifierType; }; +export type TokenResourceIdentifierInput = { + id: Scalars['String']; + type: TokenResourceIdentifierType; +}; + +export enum TokenResourceIdentifierType { + Project = 'project' +} + export type UpdateModelInput = { description?: InputMaybe; id: Scalars['ID']; @@ -2961,7 +2976,7 @@ export type TokenAppInfoQueryVariables = Exact<{ [key: string]: never; }>; export type TokenAppInfoQuery = { __typename?: 'Query', authenticatedAsApp?: { __typename?: 'ServerAppListItem', id: string, name: string } | null }; export type AppTokenCreateMutationVariables = Exact<{ - token: ApiTokenCreateInput; + token: AppTokenCreateInput; }>; @@ -3042,6 +3057,19 @@ export type DeleteCommitsMutationVariables = Exact<{ export type DeleteCommitsMutation = { __typename?: 'Mutation', commitsDelete: boolean }; +export type BasicProjectFieldsFragment = { __typename?: 'Project', id: string, name: string, description?: string | null, visibility: ProjectVisibility, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string }; + +export type AdminProjectListQueryVariables = Exact<{ + query?: InputMaybe; + orderBy?: InputMaybe; + visibility?: InputMaybe; + limit?: Scalars['Int']; + cursor?: InputMaybe; +}>; + + +export type AdminProjectListQuery = { __typename?: 'Query', admin: { __typename?: 'AdminQueries', projectList: { __typename?: 'ProjectCollection', cursor?: string | null, totalCount: number, items: Array<{ __typename?: 'Project', id: string, name: string, description?: string | null, visibility: ProjectVisibility, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string }> } } }; + export type CreateServerInviteMutationVariables = Exact<{ input: ServerInviteCreateInput; }>; @@ -3153,6 +3181,11 @@ export type ReadStreamQueryVariables = Exact<{ export type ReadStreamQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, description?: string | null, isPublic: boolean, isDiscoverable: boolean, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string } | null }; +export type ReadStreamsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ReadStreamsQuery = { __typename?: 'Query', streams?: { __typename?: 'StreamCollection', cursor?: string | null, totalCount: number, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, isPublic: boolean, isDiscoverable: boolean, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string }> | null } | null }; + export type ReadDiscoverableStreamsQueryVariables = Exact<{ limit?: Scalars['Int']; cursor?: InputMaybe; @@ -3220,6 +3253,7 @@ export type RequestVerificationMutation = { __typename?: 'Mutation', requestVeri export const BasicStreamAccessRequestFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const CommentWithRepliesFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CommentWithReplies"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"attachments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fileName"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"replies"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"attachments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fileName"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const BaseCommitFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BaseCommitFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Commit"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorId"}},{"kind":"Field","name":{"kind":"Name","value":"authorAvatar"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"streamName"}},{"kind":"Field","name":{"kind":"Name","value":"sourceApplication"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"commentCount"}}]}}]} as unknown as DocumentNode; +export const BasicProjectFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const StreamInviteDataFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"StreamInviteData"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingStreamCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}}]}}]} as unknown as DocumentNode; export const BasicStreamFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Stream"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"isDiscoverable"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const BaseUserFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BaseUserFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]} as unknown as DocumentNode; @@ -3232,7 +3266,7 @@ export const UseStreamAccessRequestDocument = {"kind":"Document","definitions":[ export const CreateTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApiTokenCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiTokenCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}]}]}}]} as unknown as DocumentNode; export const RevokeTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiTokenRevoke"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}]}]}}]} as unknown as DocumentNode; export const TokenAppInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TokenAppInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authenticatedAsApp"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; -export const AppTokenCreateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AppTokenCreate"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApiTokenCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appTokenCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}]}]}}]} as unknown as DocumentNode; +export const AppTokenCreateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AppTokenCreate"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AppTokenCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appTokenCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}]}]}}]} as unknown as DocumentNode; export const CreateCommentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateComment"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CommentCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commentCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const CreateReplyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateReply"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ReplyCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commentReply"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const GetCommentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetComment"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comment"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CommentWithReplies"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CommentWithReplies"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"attachments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fileName"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"replies"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"attachments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fileName"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; @@ -3242,6 +3276,7 @@ export const ReadOtherUsersCommitsDocument = {"kind":"Document","definitions":[{ export const ReadStreamBranchCommitsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ReadStreamBranchCommits"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"branchName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"10"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"branch"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"branchName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BaseCommitFields"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BaseCommitFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Commit"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorId"}},{"kind":"Field","name":{"kind":"Name","value":"authorAvatar"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"streamName"}},{"kind":"Field","name":{"kind":"Name","value":"sourceApplication"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"commentCount"}}]}}]} as unknown as DocumentNode; export const MoveCommitsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"MoveCommits"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CommitsMoveInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commitsMove"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const DeleteCommitsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteCommits"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CommitsDeleteInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commitsDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; +export const AdminProjectListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminProjectList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"query"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"visibility"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"25"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}},"defaultValue":{"kind":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"admin"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"query"},"value":{"kind":"Variable","name":{"kind":"Name","value":"query"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderBy"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}}},{"kind":"Argument","name":{"kind":"Name","value":"visibility"},"value":{"kind":"Variable","name":{"kind":"Name","value":"visibility"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const CreateServerInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServerInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInviteCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const CreateStreamInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStreamInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"StreamInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamInviteCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const ResendInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResendInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteResend"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}]}]}}]} as unknown as DocumentNode; @@ -3257,6 +3292,7 @@ export const LeaveStreamDocument = {"kind":"Document","definitions":[{"kind":"Op export const CreateStreamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStream"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stream"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"StreamCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"stream"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stream"}}}]}]}}]} as unknown as DocumentNode; export const UpdateStreamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateStream"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stream"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"StreamUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamUpdate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"stream"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stream"}}}]}]}}]} as unknown as DocumentNode; export const ReadStreamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ReadStream"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Stream"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"isDiscoverable"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; +export const ReadStreamsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ReadStreams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Stream"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"isDiscoverable"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const ReadDiscoverableStreamsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ReadDiscoverableStreams"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"25"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"DiscoverableStreamsSortingInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"discoverableStreams"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"favoritesCount"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Stream"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"isDiscoverable"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const GetUserStreamsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserStreams"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"25"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streams"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamFields"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Stream"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"isDiscoverable"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const GetLimitedUserStreamsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLimitedUserStreams"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"25"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"otherUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streams"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamFields"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Stream"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"isDiscoverable"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; diff --git a/packages/server/test/graphql/projects.ts b/packages/server/test/graphql/projects.ts new file mode 100644 index 0000000000..ed2d8ebdd1 --- /dev/null +++ b/packages/server/test/graphql/projects.ts @@ -0,0 +1,50 @@ +import { gql } from 'apollo-server-express' + +export const basicProjectFieldsFragment = gql` + fragment BasicProjectFields on Project { + id + name + description + visibility + allowPublicComments + role + createdAt + updatedAt + } +` + +/** + * query: String + orderBy: String + visibility: String + limit: Int! = 25 + cursor: String = null + */ + +export const adminProjectList = gql` + query AdminProjectList( + $query: String + $orderBy: String + $visibility: String + $limit: Int! = 25 + $cursor: String = null + ) { + admin { + projectList( + query: $query + orderBy: $orderBy + visibility: $visibility + limit: $limit + cursor: $cursor + ) { + cursor + totalCount + items { + ...BasicProjectFields + } + } + } + } + + ${basicProjectFieldsFragment} +` diff --git a/packages/server/test/graphql/streams.ts b/packages/server/test/graphql/streams.ts index 5777d64a48..5637758b0d 100644 --- a/packages/server/test/graphql/streams.ts +++ b/packages/server/test/graphql/streams.ts @@ -59,6 +59,20 @@ const readStreamQuery = gql` ${basicStreamFieldsFragment} ` +export const readStreamsQuery = gql` + query ReadStreams { + streams { + cursor + totalCount + items { + ...BasicStreamFields + } + } + } + + ${basicStreamFieldsFragment} +` + const readDiscoverableStreamsQuery = gql` query ReadDiscoverableStreams( $limit: Int! = 25 diff --git a/packages/shared/src/core/constants.ts b/packages/shared/src/core/constants.ts index 085665ea38..06dee33189 100644 --- a/packages/shared/src/core/constants.ts +++ b/packages/shared/src/core/constants.ts @@ -77,6 +77,9 @@ export const Scopes = Object.freeze({ Apps: { Read: 'apps:read', Write: 'apps:write' + }, + Automate: { + ReportResults: 'automate:report-results' } })