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