diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 85ba7e6ab..123826ecc 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -1000,6 +1000,22 @@ export type EmailVerificationRequestInput = { id: Scalars['ID']['input']; }; +export type EmbedToken = { + __typename?: 'EmbedToken'; + createdAt: Scalars['DateTime']['output']; + id: Scalars['String']['output']; + lastUsed: Scalars['DateTime']['output']; + lifespan: Scalars['BigInt']['output']; + modelIds: Scalars['String']['output']; + name: Scalars['String']['output']; +}; + +export type EmbedTokenCreateInput = { + lifespan?: InputMaybe; + modelIds: Scalars['String']['input']; + projectId: Scalars['String']['input']; +}; + export type FileUpload = { __typename?: 'FileUpload'; branchName: Scalars['String']['output']; @@ -2095,6 +2111,7 @@ export type Project = { description?: Maybe; /** Public project-level configuration for embedded viewer */ embedOptions: ProjectEmbedOptions; + embedTokens: Array; hasAccessToFeature: Scalars['Boolean']['output']; id: Scalars['ID']['output']; invitableCollaborators: WorkspaceCollaboratorCollection; @@ -2576,6 +2593,7 @@ export type ProjectMutations = { batchDelete: Scalars['Boolean']['output']; /** Create new project */ create: Project; + createEmbedToken: Scalars['String']['output']; /** * Create onboarding/tutorial project. If one is already created for the active user, that * one will be returned instead. @@ -2587,6 +2605,7 @@ export type ProjectMutations = { invites: ProjectInviteMutations; /** Leave a project. Only possible if you're not the last remaining owner. */ leave: Scalars['Boolean']['output']; + revokeEmbedToken: Scalars['Boolean']['output']; /** Updates an existing project */ update: Project; /** Update role for a collaborator */ @@ -2609,6 +2628,11 @@ export type ProjectMutationsCreateArgs = { }; +export type ProjectMutationsCreateEmbedTokenArgs = { + token: EmbedTokenCreateInput; +}; + + export type ProjectMutationsDeleteArgs = { id: Scalars['String']['input']; }; @@ -2619,6 +2643,11 @@ export type ProjectMutationsLeaveArgs = { }; +export type ProjectMutationsRevokeEmbedTokenArgs = { + token: Scalars['String']['input']; +}; + + export type ProjectMutationsUpdateArgs = { update: ProjectUpdateInput; }; @@ -7901,6 +7930,7 @@ export type AllObjectTypes = { CommitCollection: CommitCollection, CountOnlyCollection: CountOnlyCollection, CurrencyBasedPrices: CurrencyBasedPrices, + EmbedToken: EmbedToken, FileUpload: FileUpload, FileUploadCollection: FileUploadCollection, FileUploadMutations: FileUploadMutations, @@ -8363,6 +8393,14 @@ export type CurrencyBasedPricesFieldArgs = { gbp: {}, usd: {}, } +export type EmbedTokenFieldArgs = { + createdAt: {}, + id: {}, + lastUsed: {}, + lifespan: {}, + modelIds: {}, + name: {}, +} export type FileUploadFieldArgs = { branchName: {}, convertedCommitId: {}, @@ -8656,6 +8694,7 @@ export type ProjectFieldArgs = { createdAt: {}, description: {}, embedOptions: {}, + embedTokens: {}, hasAccessToFeature: ProjectHasAccessToFeatureArgs, id: {}, invitableCollaborators: ProjectInvitableCollaboratorsArgs, @@ -8762,10 +8801,12 @@ export type ProjectMutationsFieldArgs = { automationMutations: ProjectMutationsAutomationMutationsArgs, batchDelete: ProjectMutationsBatchDeleteArgs, create: ProjectMutationsCreateArgs, + createEmbedToken: ProjectMutationsCreateEmbedTokenArgs, createForOnboarding: {}, delete: ProjectMutationsDeleteArgs, invites: {}, leave: ProjectMutationsLeaveArgs, + revokeEmbedToken: ProjectMutationsRevokeEmbedTokenArgs, update: ProjectMutationsUpdateArgs, updateRole: ProjectMutationsUpdateRoleArgs, } @@ -9529,6 +9570,7 @@ export type AllObjectFieldArgTypes = { CommitCollection: CommitCollectionFieldArgs, CountOnlyCollection: CountOnlyCollectionFieldArgs, CurrencyBasedPrices: CurrencyBasedPricesFieldArgs, + EmbedToken: EmbedTokenFieldArgs, FileUpload: FileUploadFieldArgs, FileUploadCollection: FileUploadCollectionFieldArgs, FileUploadMutations: FileUploadMutationsFieldArgs, diff --git a/packages/server/assets/core/typedefs/apitoken.graphql b/packages/server/assets/core/typedefs/apitoken.graphql index 90f73cc50..b7f673cbd 100644 --- a/packages/server/assets/core/typedefs/apitoken.graphql +++ b/packages/server/assets/core/typedefs/apitoken.graphql @@ -55,6 +55,33 @@ input AppTokenCreateInput { limitResources: [TokenResourceIdentifierInput!] } +""" +A token used to enable an embedded viewer for a private project +""" +type EmbedToken { + tokenId: String! + projectId: String! + user: LimitedUser + resourceIdString: String! + createdAt: DateTime! + lifespan: BigInt! + lastUsed: DateTime! +} + +input EmbedTokenCreateInput { + projectId: String! + """ + The model(s) and version(s) string used in the embed url + """ + resourceIdString: String! + lifespan: BigInt +} + +type CreateEmbedTokenReturn { + token: String! + tokenMetadata: EmbedToken! +} + extend type Mutation { """ Creates an personal api token. @@ -77,3 +104,15 @@ extend type Mutation { @hasServerRole(role: SERVER_USER) @hasScope(scope: "tokens:write") } + +extend type ProjectMutations { + createEmbedToken(token: EmbedTokenCreateInput!): CreateEmbedTokenReturn! + @hasScope(scope: "tokens:write") + revokeEmbedToken(token: String!, projectId: String!): Boolean! + @hasScope(scope: "tokens:write") + revokeEmbedTokens(projectId: String!): Boolean! @hasScope(scope: "tokens:write") +} + +extend type Project { + embedTokens: [EmbedToken!]! +} diff --git a/packages/server/codegen.yml b/packages/server/codegen.yml index ba69456fe..5bd42aa19 100644 --- a/packages/server/codegen.yml +++ b/packages/server/codegen.yml @@ -24,6 +24,7 @@ generates: ProjectAccessRequestMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn' LimitedUser: '@/modules/core/helpers/graphTypes#LimitedUserGraphQLReturn' User: '@/modules/core/helpers/graphTypes#UserGraphQLReturn' + EmbedToken: '@/modules/core/helpers/graphTypes#EmbedTokenGraphQLReturn' ActiveUserMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn' UserMetaMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn' UserEmailMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn' diff --git a/packages/server/modules/auth/helpers/types.ts b/packages/server/modules/auth/helpers/types.ts index 024e552eb..10f084848 100644 --- a/packages/server/modules/auth/helpers/types.ts +++ b/packages/server/modules/auth/helpers/types.ts @@ -74,6 +74,13 @@ export type PersonalApiTokenRecord = { tokenId: string } +export type EmbedApiTokenRecord = { + projectId: string + tokenId: string + userId: string + resourceIdString: string +} + export type TokenScopeRecord = { tokenId: string scopeName: ServerScope diff --git a/packages/server/modules/core/dbSchema.ts b/packages/server/modules/core/dbSchema.ts index 201eca27b..d0f6528a8 100644 --- a/packages/server/modules/core/dbSchema.ts +++ b/packages/server/modules/core/dbSchema.ts @@ -406,6 +406,13 @@ export const PersonalApiTokens = buildTableHelper('personal_api_tokens', [ 'userId' ]) +export const EmbedApiTokens = buildTableHelper('embed_api_tokens', [ + 'tokenId', + 'projectId', + 'userId', + 'resourceIdString' +]) + export const UserServerAppTokens = buildTableHelper('user_server_app_tokens', [ 'appId', 'userId', diff --git a/packages/server/modules/core/domain/tokens/operations.ts b/packages/server/modules/core/domain/tokens/operations.ts index c26528852..89f1c1d41 100644 --- a/packages/server/modules/core/domain/tokens/operations.ts +++ b/packages/server/modules/core/domain/tokens/operations.ts @@ -1,5 +1,6 @@ import { ApiToken, + EmbedApiToken, PersonalApiToken, TokenResourceAccessDefinition, TokenResourceIdentifierType, @@ -31,6 +32,8 @@ export type StorePersonalApiToken = ( token: PersonalApiToken ) => Promise +export type StoreEmbedApiToken = (token: EmbedApiToken) => Promise + export type GetUserPersonalAccessTokens = (userId: string) => Promise< { id: string @@ -43,10 +46,25 @@ export type GetUserPersonalAccessTokens = (userId: string) => Promise< }[] > +export type ListProjectEmbedTokens = (args: { projectId: string }) => Promise< + (EmbedApiToken & { + createdAt: Date + lastUsed: Date + lifespan: number | bigint + })[] +> + export type RevokeTokenById = (tokenId: string) => Promise export type RevokeUserTokenById = (tokenId: string, userId: string) => Promise +export type RevokeEmbedTokenById = (args: { + tokenId: string + projectId: string +}) => Promise + +export type RevokeProjectEmbedTokens = (args: { projectId: string }) => Promise + export type GetApiTokenById = (tokenId: string) => Promise> export type GetTokenScopesById = (tokenId: string) => Promise @@ -86,4 +104,18 @@ export type CreateAndStorePersonalAccessToken = ( lifespan?: number | bigint ) => Promise +export type CreateAndStoreEmbedToken = (args: { + projectId: string + userId: string + /** + * The models (and optional versions) included in the embed. + * @example 'foo123,bar456@baz789' + */ + resourceIdString: string + lifespan?: number | bigint +}) => Promise<{ + token: string + tokenMetadata: EmbedApiToken +}> + export type ValidateToken = (tokenString: string) => Promise diff --git a/packages/server/modules/core/domain/tokens/types.ts b/packages/server/modules/core/domain/tokens/types.ts index 5b6f14888..7d0a00e96 100644 --- a/packages/server/modules/core/domain/tokens/types.ts +++ b/packages/server/modules/core/domain/tokens/types.ts @@ -1,4 +1,5 @@ import { + EmbedApiTokenRecord, PersonalApiTokenRecord, TokenScopeRecord, UserServerAppTokenRecord @@ -26,3 +27,5 @@ export type TokenResourceAccessDefinition = TokenResourceAccessRecord export type UserServerAppToken = UserServerAppTokenRecord export type PersonalApiToken = PersonalApiTokenRecord + +export type EmbedApiToken = EmbedApiTokenRecord diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 7b58fb92a..795f3f156 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -1,5 +1,5 @@ import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql'; -import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, ObjectGraphQLReturn, VersionGraphQLReturn, ServerInviteGraphQLReturnType, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, MutationsObjectGraphQLReturn, LimitedUserGraphQLReturn, UserGraphQLReturn, GraphQLEmptyReturn, StreamCollaboratorGraphQLReturn, ProjectCollaboratorGraphQLReturn, ServerInfoGraphQLReturn, BranchGraphQLReturn, UserMetaGraphQLReturn, ProjectPermissionChecksGraphQLReturn, ModelPermissionChecksGraphQLReturn, VersionPermissionChecksGraphQLReturn, RootPermissionChecksGraphQLReturn } from '@/modules/core/helpers/graphTypes'; +import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, ObjectGraphQLReturn, VersionGraphQLReturn, ServerInviteGraphQLReturnType, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, MutationsObjectGraphQLReturn, LimitedUserGraphQLReturn, UserGraphQLReturn, EmbedTokenGraphQLReturn, GraphQLEmptyReturn, StreamCollaboratorGraphQLReturn, ProjectCollaboratorGraphQLReturn, ServerInfoGraphQLReturn, BranchGraphQLReturn, UserMetaGraphQLReturn, ProjectPermissionChecksGraphQLReturn, ModelPermissionChecksGraphQLReturn, VersionPermissionChecksGraphQLReturn, RootPermissionChecksGraphQLReturn } from '@/modules/core/helpers/graphTypes'; import { StreamAccessRequestGraphQLReturn, ProjectAccessRequestGraphQLReturn } from '@/modules/accessrequests/helpers/graphTypes'; import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn, CommentPermissionChecksGraphQLReturn } from '@/modules/comments/helpers/graphTypes'; import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/helpers/graphTypes'; @@ -945,6 +945,12 @@ export type CreateCommentReplyInput = { threadId: Scalars['String']['input']; }; +export type CreateEmbedTokenReturn = { + __typename?: 'CreateEmbedTokenReturn'; + token: Scalars['String']['output']; + tokenMetadata: EmbedToken; +}; + export type CreateModelInput = { description?: InputMaybe; name: Scalars['String']['input']; @@ -1023,6 +1029,25 @@ export type EmailVerificationRequestInput = { id: Scalars['ID']['input']; }; +/** A token used to enable an embedded viewer for a private project */ +export type EmbedToken = { + __typename?: 'EmbedToken'; + createdAt: Scalars['DateTime']['output']; + lastUsed: Scalars['DateTime']['output']; + lifespan: Scalars['BigInt']['output']; + projectId: Scalars['String']['output']; + resourceIdString: Scalars['String']['output']; + tokenId: Scalars['String']['output']; + user?: Maybe; +}; + +export type EmbedTokenCreateInput = { + lifespan?: InputMaybe; + projectId: Scalars['String']['input']; + /** The model(s) and version(s) string used in the embed url */ + resourceIdString: Scalars['String']['input']; +}; + export type FileUpload = { __typename?: 'FileUpload'; branchName: Scalars['String']['output']; @@ -2118,6 +2143,7 @@ export type Project = { description?: Maybe; /** Public project-level configuration for embedded viewer */ embedOptions: ProjectEmbedOptions; + embedTokens: Array; hasAccessToFeature: Scalars['Boolean']['output']; id: Scalars['ID']['output']; invitableCollaborators: WorkspaceCollaboratorCollection; @@ -2599,6 +2625,7 @@ export type ProjectMutations = { batchDelete: Scalars['Boolean']['output']; /** Create new project */ create: Project; + createEmbedToken: CreateEmbedTokenReturn; /** * Create onboarding/tutorial project. If one is already created for the active user, that * one will be returned instead. @@ -2610,6 +2637,8 @@ export type ProjectMutations = { invites: ProjectInviteMutations; /** Leave a project. Only possible if you're not the last remaining owner. */ leave: Scalars['Boolean']['output']; + revokeEmbedToken: Scalars['Boolean']['output']; + revokeEmbedTokens: Scalars['Boolean']['output']; /** Updates an existing project */ update: Project; /** Update role for a collaborator */ @@ -2632,6 +2661,11 @@ export type ProjectMutationsCreateArgs = { }; +export type ProjectMutationsCreateEmbedTokenArgs = { + token: EmbedTokenCreateInput; +}; + + export type ProjectMutationsDeleteArgs = { id: Scalars['String']['input']; }; @@ -2642,6 +2676,17 @@ export type ProjectMutationsLeaveArgs = { }; +export type ProjectMutationsRevokeEmbedTokenArgs = { + projectId: Scalars['String']['input']; + token: Scalars['String']['input']; +}; + + +export type ProjectMutationsRevokeEmbedTokensArgs = { + projectId: Scalars['String']['input']; +}; + + export type ProjectMutationsUpdateArgs = { update: ProjectUpdateInput; }; @@ -5429,6 +5474,7 @@ export type ResolversTypes = { CreateAutomateFunctionWithoutVersionInput: CreateAutomateFunctionWithoutVersionInput; CreateCommentInput: CreateCommentInput; CreateCommentReplyInput: CreateCommentReplyInput; + CreateEmbedTokenReturn: ResolverTypeWrapper & { tokenMetadata: ResolversTypes['EmbedToken'] }>; CreateModelInput: CreateModelInput; CreateServerRegionInput: CreateServerRegionInput; CreateUserEmailInput: CreateUserEmailInput; @@ -5444,6 +5490,8 @@ export type ResolversTypes = { DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput; EditCommentInput: EditCommentInput; EmailVerificationRequestInput: EmailVerificationRequestInput; + EmbedToken: ResolverTypeWrapper; + EmbedTokenCreateInput: EmbedTokenCreateInput; FileUpload: ResolverTypeWrapper; FileUploadCollection: ResolverTypeWrapper & { items: Array }>; FileUploadMutations: ResolverTypeWrapper; @@ -5773,6 +5821,7 @@ export type ResolversParentTypes = { CreateAutomateFunctionWithoutVersionInput: CreateAutomateFunctionWithoutVersionInput; CreateCommentInput: CreateCommentInput; CreateCommentReplyInput: CreateCommentReplyInput; + CreateEmbedTokenReturn: Omit & { tokenMetadata: ResolversParentTypes['EmbedToken'] }; CreateModelInput: CreateModelInput; CreateServerRegionInput: CreateServerRegionInput; CreateUserEmailInput: CreateUserEmailInput; @@ -5786,6 +5835,8 @@ export type ResolversParentTypes = { DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput; EditCommentInput: EditCommentInput; EmailVerificationRequestInput: EmailVerificationRequestInput; + EmbedToken: EmbedTokenGraphQLReturn; + EmbedTokenCreateInput: EmbedTokenCreateInput; FileUpload: FileUploadGraphQLReturn; FileUploadCollection: Omit & { items: Array }; FileUploadMutations: MutationsObjectGraphQLReturn; @@ -6458,6 +6509,12 @@ export type CountOnlyCollectionResolvers; }; +export type CreateEmbedTokenReturnResolvers = { + token?: Resolver; + tokenMetadata?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CurrencyBasedPricesResolvers = { gbp?: Resolver; usd?: Resolver; @@ -6468,6 +6525,17 @@ export interface DateTimeScalarConfig extends GraphQLScalarTypeConfig = { + createdAt?: Resolver; + lastUsed?: Resolver; + lifespan?: Resolver; + projectId?: Resolver; + resourceIdString?: Resolver; + tokenId?: Resolver; + user?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type FileUploadResolvers = { branchName?: Resolver; convertedCommitId?: Resolver, ParentType, ContextType>; @@ -6820,6 +6888,7 @@ export type ProjectResolvers; description?: Resolver, ParentType, ContextType>; embedOptions?: Resolver; + embedTokens?: Resolver, ParentType, ContextType>; hasAccessToFeature?: Resolver>; id?: Resolver; invitableCollaborators?: Resolver>; @@ -6954,10 +7023,13 @@ export type ProjectMutationsResolvers>; batchDelete?: Resolver>; create?: Resolver>; + createEmbedToken?: Resolver>; createForOnboarding?: Resolver; delete?: Resolver>; invites?: Resolver; leave?: Resolver>; + revokeEmbedToken?: Resolver>; + revokeEmbedTokens?: Resolver>; update?: Resolver>; updateRole?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; @@ -7911,8 +7983,10 @@ export type Resolvers = { Commit?: CommitResolvers; CommitCollection?: CommitCollectionResolvers; CountOnlyCollection?: CountOnlyCollectionResolvers; + CreateEmbedTokenReturn?: CreateEmbedTokenReturnResolvers; CurrencyBasedPrices?: CurrencyBasedPricesResolvers; DateTime?: GraphQLScalarType; + EmbedToken?: EmbedTokenResolvers; FileUpload?: FileUploadResolvers; FileUploadCollection?: FileUploadCollectionResolvers; FileUploadMutations?: FileUploadMutationsResolvers; diff --git a/packages/server/modules/core/graph/resolvers/embedTokens.ts b/packages/server/modules/core/graph/resolvers/embedTokens.ts new file mode 100644 index 000000000..8b5aa0406 --- /dev/null +++ b/packages/server/modules/core/graph/resolvers/embedTokens.ts @@ -0,0 +1,107 @@ +import { db } from '@/db/knex' +import { Resolvers } from '@/modules/core/graph/generated/graphql' +import { + storeApiTokenFactory, + storeTokenResourceAccessDefinitionsFactory, + storeTokenScopesFactory +} from '@/modules/core/repositories/tokens' +import { + listProjectEmbedTokensFactory, + revokeEmbedTokenByIdFactory, + revokeProjectEmbedTokensFactory, + storeEmbedApiTokenFactory +} from '@/modules/core/repositories/embedTokens' +import { + createEmbedTokenFactory, + createTokenFactory +} from '@/modules/core/services/tokens' +import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper' +import { removeNullOrUndefinedKeys } from '@speckle/shared' +import { getUserFactory } from '@/modules/core/repositories/users' +import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token' + +const resolvers: Resolvers = { + EmbedToken: { + user: async (parent) => { + return await getUserFactory({ db })(parent.userId) + } + }, + Project: { + embedTokens: async (parent, _args, context) => { + const canReadEmbedTokens = await context.authPolicies.project.canReadEmbedTokens({ + userId: context.userId, + projectId: parent.id + }) + throwIfAuthNotOk(canReadEmbedTokens) + + return await listProjectEmbedTokensFactory({ db })({ + projectId: parent.id + }) + } + }, + ProjectMutations: { + createEmbedToken: async (_parent, args, context) => { + const canCreateEmbedToken = + await context.authPolicies.project.canUpdateEmbedTokens({ + userId: context.userId, + projectId: args.token.projectId + }) + throwIfAuthNotOk(canCreateEmbedToken) + throwIfResourceAccessNotAllowed({ + resourceId: args.token.projectId, + resourceType: 'project', + resourceAccessRules: context.resourceAccessRules + }) + + return await createEmbedTokenFactory({ + createToken: createTokenFactory({ + storeApiToken: storeApiTokenFactory({ db }), + storeTokenScopes: storeTokenScopesFactory({ db }), + storeTokenResourceAccessDefinitions: + storeTokenResourceAccessDefinitionsFactory({ db }) + }), + storeEmbedToken: storeEmbedApiTokenFactory({ db }) + })({ + ...removeNullOrUndefinedKeys(args.token), + userId: context.userId! + }) + }, + revokeEmbedToken: async (_parent, args, context) => { + const canRevokeEmbedToken = + await context.authPolicies.project.canUpdateEmbedTokens({ + userId: context.userId, + projectId: args.projectId + }) + throwIfAuthNotOk(canRevokeEmbedToken) + throwIfResourceAccessNotAllowed({ + resourceId: args.projectId, + resourceType: 'project', + resourceAccessRules: context.resourceAccessRules + }) + + return await revokeEmbedTokenByIdFactory({ db })({ + tokenId: args.token, + projectId: args.projectId + }) + }, + revokeEmbedTokens: async (_parent, args, context) => { + const canRevokeEmbedTokens = + await context.authPolicies.project.canUpdateEmbedTokens({ + userId: context.userId, + projectId: args.projectId + }) + throwIfAuthNotOk(canRevokeEmbedTokens) + throwIfResourceAccessNotAllowed({ + resourceId: args.projectId, + resourceType: 'project', + resourceAccessRules: context.resourceAccessRules + }) + + await revokeProjectEmbedTokensFactory({ db })({ projectId: args.projectId }) + + return true + } + } +} + +export default resolvers diff --git a/packages/server/modules/core/helpers/graphTypes.ts b/packages/server/modules/core/helpers/graphTypes.ts index 75500c608..43e0f734b 100644 --- a/packages/server/modules/core/helpers/graphTypes.ts +++ b/packages/server/modules/core/helpers/graphTypes.ts @@ -3,6 +3,7 @@ import { LegacyStreamCommit, LegacyUserCommit } from '@/modules/core/domain/commits/types' +import { EmbedApiToken } from '@/modules/core/domain/tokens/types' import { LimitedUser, StreamRole, @@ -154,3 +155,5 @@ export type VersionPermissionChecksGraphQLReturn = { versionId: string projectId: string } + +export type EmbedTokenGraphQLReturn = EmbedApiToken diff --git a/packages/server/modules/core/migrations/20250630073647_add_embed_tokens.ts b/packages/server/modules/core/migrations/20250630073647_add_embed_tokens.ts new file mode 100644 index 000000000..07db597c3 --- /dev/null +++ b/packages/server/modules/core/migrations/20250630073647_add_embed_tokens.ts @@ -0,0 +1,30 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('embed_api_tokens', (table) => { + table + .string('tokenId') + .notNullable() + .references('id') + .inTable('api_tokens') + .onDelete('cascade') + table + .string('projectId') + .notNullable() + .references('id') + .inTable('streams') + .onDelete('cascade') + table + .string('userId') + .notNullable() + .references('id') + .inTable('users') + .onDelete('cascade') + table.string('resourceIdString').notNullable() + table.primary(['projectId', 'tokenId']) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('embed_api_tokens') +} diff --git a/packages/server/modules/core/repositories/embedTokens.ts b/packages/server/modules/core/repositories/embedTokens.ts new file mode 100644 index 000000000..a3d84dd82 --- /dev/null +++ b/packages/server/modules/core/repositories/embedTokens.ts @@ -0,0 +1,67 @@ +import { EmbedApiTokenRecord } from '@/modules/auth/helpers/types' +import { ApiTokenRecord } from '@/modules/auth/repositories' +import { ApiTokens, EmbedApiTokens } from '@/modules/core/dbSchema' +import { + ListProjectEmbedTokens, + RevokeEmbedTokenById, + RevokeProjectEmbedTokens, + StoreEmbedApiToken +} from '@/modules/core/domain/tokens/operations' +import { UserInputError } from '@/modules/core/errors/userinput' +import { Knex } from 'knex' + +const tables = { + apiTokens: (db: Knex) => db(ApiTokens.name), + embedApiTokens: (db: Knex) => db(EmbedApiTokens.name) +} + +export const storeEmbedApiTokenFactory = + (deps: { db: Knex }): StoreEmbedApiToken => + async (token) => { + const [newToken] = await tables.embedApiTokens(deps.db).insert(token).returning('*') + return newToken + } + +export const listProjectEmbedTokensFactory = + (deps: { db: Knex }): ListProjectEmbedTokens => + async ({ projectId }) => { + return (await tables + .embedApiTokens(deps.db) + .select( + ...EmbedApiTokens.cols, + ApiTokens.col.createdAt, + ApiTokens.col.lastUsed, + ApiTokens.col.lifespan + ) + .orderBy(ApiTokens.col.createdAt, 'desc') + .leftJoin(ApiTokens.name, ApiTokens.col.id, EmbedApiTokens.col.tokenId) + .where(EmbedApiTokens.col.projectId, projectId)) as (EmbedApiTokenRecord & + Pick)[] + } + +export const revokeEmbedTokenByIdFactory = + (deps: { db: Knex }): RevokeEmbedTokenById => + async ({ tokenId: token, projectId }) => { + const tokenId = token.slice(0, 10) + const delCount = await tables + .embedApiTokens(deps.db) + .where({ tokenId, projectId }) + .delete() + if (delCount === 0) throw new UserInputError('Embed token not found') + await tables.apiTokens(deps.db).where(ApiTokens.col.id, tokenId).delete() + return true + } + +export const revokeProjectEmbedTokensFactory = + (deps: { db: Knex }): RevokeProjectEmbedTokens => + async ({ projectId }) => { + await tables + .apiTokens(deps.db) + .whereIn(ApiTokens.col.id, (builder) => { + return builder + .select('tokenId') + .from(EmbedApiTokens.name) + .where('projectId', projectId) + }) + .delete() + } diff --git a/packages/server/modules/core/repositories/tokens.ts b/packages/server/modules/core/repositories/tokens.ts index a1f53581c..a643d9d9c 100644 --- a/packages/server/modules/core/repositories/tokens.ts +++ b/packages/server/modules/core/repositories/tokens.ts @@ -1,4 +1,5 @@ import { + EmbedApiTokenRecord, PersonalApiTokenRecord, TokenScopeRecord, UserServerAppTokenRecord @@ -6,6 +7,7 @@ import { import { ApiTokenRecord } from '@/modules/auth/repositories' import { ApiTokens, + EmbedApiTokens, PersonalApiTokens, TokenResourceAccess, TokenScopes, @@ -38,7 +40,8 @@ const tables = { db(TokenResourceAccess.name), userServerAppTokens: (db: Knex) => db(UserServerAppTokens.name), - personalApiTokens: (db: Knex) => db(PersonalApiTokens.name) + personalApiTokens: (db: Knex) => db(PersonalApiTokens.name), + embedApiTokens: (db: Knex) => db(EmbedApiTokens.name) } export const storeApiTokenFactory = diff --git a/packages/server/modules/core/services/tokens.ts b/packages/server/modules/core/services/tokens.ts index a883c66ff..3e5d674f9 100644 --- a/packages/server/modules/core/services/tokens.ts +++ b/packages/server/modules/core/services/tokens.ts @@ -4,9 +4,10 @@ import { TokenResourceAccessRecord, TokenValidationResult } from '@/modules/core/helpers/types' -import { Optional, ServerScope } from '@speckle/shared' +import { Optional, Scopes, ServerScope } from '@speckle/shared' import { CreateAndStoreAppToken, + CreateAndStoreEmbedToken, CreateAndStorePersonalAccessToken, CreateAndStoreUserToken, GetApiTokenById, @@ -14,6 +15,7 @@ import { GetTokenScopesById, RevokeUserTokenById, StoreApiToken, + StoreEmbedApiToken, StorePersonalApiToken, StoreTokenResourceAccessDefinitions, StoreTokenScopes, @@ -24,6 +26,15 @@ import { import { GetTokenAppInfo } from '@/modules/auth/domain/operations' import { GetUserRole } from '@/modules/core/domain/users/operations' import { TokenCreateError } from '@/modules/core/errors/user' +import cryptoRandomString from 'crypto-random-string' +import { + EmbedApiToken, + TokenResourceIdentifierType +} from '@/modules/core/domain/tokens/types' +import { + createGetParamFromResources, + parseUrlParameters +} from '@speckle/shared/viewer/route' /* Tokens @@ -124,6 +135,41 @@ export const createPersonalAccessTokenFactory = return token } +export const createEmbedTokenFactory = + (deps: { + createToken: CreateAndStoreUserToken + storeEmbedToken: StoreEmbedApiToken + }): CreateAndStoreEmbedToken => + async ({ projectId, userId, resourceIdString, lifespan }) => { + const validatedResourceIdString = createGetParamFromResources( + parseUrlParameters(resourceIdString) + ) + + const { id, token } = await deps.createToken({ + userId, + name: cryptoRandomString({ length: 10 }), + scopes: [Scopes.Streams.Read], + limitResources: [ + { + id: projectId, + type: TokenResourceIdentifierType.Project + } + ], + lifespan + }) + + const tokenMetadata: EmbedApiToken = { + projectId, + tokenId: id, + userId, + resourceIdString: validatedResourceIdString + } + + await deps.storeEmbedToken(tokenMetadata) + + return { token, tokenMetadata } + } + export const validateTokenFactory = (deps: { revokeUserTokenById: RevokeUserTokenById diff --git a/packages/server/modules/core/tests/embedTokens.spec.ts b/packages/server/modules/core/tests/embedTokens.spec.ts new file mode 100644 index 000000000..b805729da --- /dev/null +++ b/packages/server/modules/core/tests/embedTokens.spec.ts @@ -0,0 +1,120 @@ +import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types' +import { AllScopes } from '@/modules/core/helpers/mainConstants' +import { + createRandomEmail, + createRandomPassword +} from '@/modules/core/helpers/testHelpers' +import { + BasicTestWorkspace, + createTestWorkspace +} from '@/modules/workspaces/tests/helpers/creation' +import { BasicTestUser, createTestUser } from '@/test/authHelper' +import { + CreateEmbedTokenDocument, + GetActiveUserDocument, + GetProjectDocument, + GetWorkspaceDocument +} from '@/test/graphql/generated/graphql' +import { + createTestContext, + testApolloServer, + TestApolloServer +} from '@/test/graphqlHelper' +import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper' +import { Roles, Scopes } from '@speckle/shared' +import { expect } from 'chai' + +describe('Embed tokens', () => { + const adminUser: BasicTestUser = { + id: '', + name: 'John Speckle', + email: createRandomEmail(), + password: createRandomPassword() + } + + const workspace: BasicTestWorkspace = { + id: '', + ownerId: '', + name: 'My Workspace', + slug: '' + } + + const projectA: BasicTestStream = { + id: '', + ownerId: '', + name: 'My Project' + } + const projectB: BasicTestStream = { + id: '', + ownerId: '', + name: 'My Project 2' + } + + let apollo: TestApolloServer + + before(async () => { + await createTestUser(adminUser) + + await createTestWorkspace(workspace, adminUser) + + projectA.workspaceId = workspace.id + projectB.workspaceId = workspace.id + + await createTestStream(projectA, adminUser) + await createTestStream(projectB, adminUser) + + const adminApollo = await testApolloServer({ + context: await createTestContext({ + auth: true, + userId: adminUser.id, + role: Roles.Server.Admin, + scopes: AllScopes, + token: 'abc' + }) + }) + + const res = await adminApollo.execute(CreateEmbedTokenDocument, { + token: { + projectId: projectA.id, + resourceIdString: 'foo123' + } + }) + const token = res.data!.projectMutations.createEmbedToken.token + + apollo = await testApolloServer({ + context: await createTestContext({ + auth: true, + userId: adminUser.id, + role: Roles.Server.Admin, + scopes: [Scopes.Streams.Read], + resourceAccessRules: [ + { id: projectA.id, type: TokenResourceIdentifierType.Project } + ], + token + }) + }) + }) + + it('can read associated project data', async () => { + const res = await apollo.execute(GetProjectDocument, { id: projectA.id }) + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.project.name).to.equal(projectA.name) + }) + + it('cannot read other project data, even if the source user has access', async () => { + const res = await apollo.execute(GetProjectDocument, { id: projectB.id }) + expect(res).to.haveGraphQLErrors() + }) + + it('cannot access source user profile', async () => { + const res = await apollo.execute(GetActiveUserDocument, {}) + expect(res).to.haveGraphQLErrors() + }) + + it('cannot access workspace data', async () => { + const res = await apollo.execute(GetWorkspaceDocument, { + workspaceId: workspace.id + }) + expect(res).to.haveGraphQLErrors() + }) +}) 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 06542fdec..77654b2e9 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -925,6 +925,12 @@ export type CreateCommentReplyInput = { threadId: Scalars['String']['input']; }; +export type CreateEmbedTokenReturn = { + __typename?: 'CreateEmbedTokenReturn'; + token: Scalars['String']['output']; + tokenMetadata: EmbedToken; +}; + export type CreateModelInput = { description?: InputMaybe; name: Scalars['String']['input']; @@ -1003,6 +1009,25 @@ export type EmailVerificationRequestInput = { id: Scalars['ID']['input']; }; +/** A token used to enable an embedded viewer for a private project */ +export type EmbedToken = { + __typename?: 'EmbedToken'; + createdAt: Scalars['DateTime']['output']; + lastUsed: Scalars['DateTime']['output']; + lifespan: Scalars['BigInt']['output']; + projectId: Scalars['String']['output']; + resourceIdString: Scalars['String']['output']; + tokenId: Scalars['String']['output']; + user?: Maybe; +}; + +export type EmbedTokenCreateInput = { + lifespan?: InputMaybe; + projectId: Scalars['String']['input']; + /** The model(s) and version(s) string used in the embed url */ + resourceIdString: Scalars['String']['input']; +}; + export type FileUpload = { __typename?: 'FileUpload'; branchName: Scalars['String']['output']; @@ -2098,6 +2123,7 @@ export type Project = { description?: Maybe; /** Public project-level configuration for embedded viewer */ embedOptions: ProjectEmbedOptions; + embedTokens: Array; hasAccessToFeature: Scalars['Boolean']['output']; id: Scalars['ID']['output']; invitableCollaborators: WorkspaceCollaboratorCollection; @@ -2579,6 +2605,7 @@ export type ProjectMutations = { batchDelete: Scalars['Boolean']['output']; /** Create new project */ create: Project; + createEmbedToken: CreateEmbedTokenReturn; /** * Create onboarding/tutorial project. If one is already created for the active user, that * one will be returned instead. @@ -2590,6 +2617,8 @@ export type ProjectMutations = { invites: ProjectInviteMutations; /** Leave a project. Only possible if you're not the last remaining owner. */ leave: Scalars['Boolean']['output']; + revokeEmbedToken: Scalars['Boolean']['output']; + revokeEmbedTokens: Scalars['Boolean']['output']; /** Updates an existing project */ update: Project; /** Update role for a collaborator */ @@ -2612,6 +2641,11 @@ export type ProjectMutationsCreateArgs = { }; +export type ProjectMutationsCreateEmbedTokenArgs = { + token: EmbedTokenCreateInput; +}; + + export type ProjectMutationsDeleteArgs = { id: Scalars['String']['input']; }; @@ -2622,6 +2656,17 @@ export type ProjectMutationsLeaveArgs = { }; +export type ProjectMutationsRevokeEmbedTokenArgs = { + projectId: Scalars['String']['input']; + token: Scalars['String']['input']; +}; + + +export type ProjectMutationsRevokeEmbedTokensArgs = { + projectId: Scalars['String']['input']; +}; + + export type ProjectMutationsUpdateArgs = { update: ProjectUpdateInput; }; diff --git a/packages/server/modules/shared/helpers/errorHelper.ts b/packages/server/modules/shared/helpers/errorHelper.ts index 5fe0836d3..93f006046 100644 --- a/packages/server/modules/shared/helpers/errorHelper.ts +++ b/packages/server/modules/shared/helpers/errorHelper.ts @@ -41,7 +41,7 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => { case Authz.WorkspaceProjectMoveInvalidError.code: case Authz.CommentNoAccessError.code: case Authz.ProjectNotEnoughPermissionsError.code: - case Authz.WorkspaceNoFeatureAccessError.code: + case Authz.WorkspacePlanNoFeatureAccessError.code: case Authz.EligibleForExclusiveWorkspaceError.code: return new ForbiddenError(e.message) case Authz.WorkspaceSsoSessionNoAccessError.code: diff --git a/packages/server/test/graphql/embedTokens.ts b/packages/server/test/graphql/embedTokens.ts new file mode 100644 index 000000000..34d6c51ae --- /dev/null +++ b/packages/server/test/graphql/embedTokens.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag' + +export const createEmbedTokenMutation = gql` + mutation CreateEmbedToken($token: EmbedTokenCreateInput!) { + projectMutations { + createEmbedToken(token: $token) { + token + } + } + } +` diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 08956ba9e..bc706d47f 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -926,6 +926,12 @@ export type CreateCommentReplyInput = { threadId: Scalars['String']['input']; }; +export type CreateEmbedTokenReturn = { + __typename?: 'CreateEmbedTokenReturn'; + token: Scalars['String']['output']; + tokenMetadata: EmbedToken; +}; + export type CreateModelInput = { description?: InputMaybe; name: Scalars['String']['input']; @@ -1004,6 +1010,25 @@ export type EmailVerificationRequestInput = { id: Scalars['ID']['input']; }; +/** A token used to enable an embedded viewer for a private project */ +export type EmbedToken = { + __typename?: 'EmbedToken'; + createdAt: Scalars['DateTime']['output']; + lastUsed: Scalars['DateTime']['output']; + lifespan: Scalars['BigInt']['output']; + projectId: Scalars['String']['output']; + resourceIdString: Scalars['String']['output']; + tokenId: Scalars['String']['output']; + user?: Maybe; +}; + +export type EmbedTokenCreateInput = { + lifespan?: InputMaybe; + projectId: Scalars['String']['input']; + /** The model(s) and version(s) string used in the embed url */ + resourceIdString: Scalars['String']['input']; +}; + export type FileUpload = { __typename?: 'FileUpload'; branchName: Scalars['String']['output']; @@ -2099,6 +2124,7 @@ export type Project = { description?: Maybe; /** Public project-level configuration for embedded viewer */ embedOptions: ProjectEmbedOptions; + embedTokens: Array; hasAccessToFeature: Scalars['Boolean']['output']; id: Scalars['ID']['output']; invitableCollaborators: WorkspaceCollaboratorCollection; @@ -2580,6 +2606,7 @@ export type ProjectMutations = { batchDelete: Scalars['Boolean']['output']; /** Create new project */ create: Project; + createEmbedToken: CreateEmbedTokenReturn; /** * Create onboarding/tutorial project. If one is already created for the active user, that * one will be returned instead. @@ -2591,6 +2618,8 @@ export type ProjectMutations = { invites: ProjectInviteMutations; /** Leave a project. Only possible if you're not the last remaining owner. */ leave: Scalars['Boolean']['output']; + revokeEmbedToken: Scalars['Boolean']['output']; + revokeEmbedTokens: Scalars['Boolean']['output']; /** Updates an existing project */ update: Project; /** Update role for a collaborator */ @@ -2613,6 +2642,11 @@ export type ProjectMutationsCreateArgs = { }; +export type ProjectMutationsCreateEmbedTokenArgs = { + token: EmbedTokenCreateInput; +}; + + export type ProjectMutationsDeleteArgs = { id: Scalars['String']['input']; }; @@ -2623,6 +2657,17 @@ export type ProjectMutationsLeaveArgs = { }; +export type ProjectMutationsRevokeEmbedTokenArgs = { + projectId: Scalars['String']['input']; + token: Scalars['String']['input']; +}; + + +export type ProjectMutationsRevokeEmbedTokensArgs = { + projectId: Scalars['String']['input']; +}; + + export type ProjectMutationsUpdateArgs = { update: ProjectUpdateInput; }; @@ -5868,6 +5913,13 @@ export type DeleteCommitsMutationVariables = Exact<{ export type DeleteCommitsMutation = { __typename?: 'Mutation', commitsDelete: boolean }; +export type CreateEmbedTokenMutationVariables = Exact<{ + token: EmbedTokenCreateInput; +}>; + + +export type CreateEmbedTokenMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', createEmbedToken: { __typename?: 'CreateEmbedTokenReturn', token: string } } }; + export type GetWorkspacePlanPricesQueryVariables = Exact<{ [key: string]: never; }>; @@ -6615,6 +6667,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 CreateEmbedTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateEmbedToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EmbedTokenCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createEmbedToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetWorkspacePlanPricesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspacePlanPrices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"planPrices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"usd"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monthly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}},{"kind":"Field","name":{"kind":"Name","value":"yearly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"teamUnlimited"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monthly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}},{"kind":"Field","name":{"kind":"Name","value":"yearly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pro"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monthly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}},{"kind":"Field","name":{"kind":"Name","value":"yearly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"proUnlimited"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monthly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}},{"kind":"Field","name":{"kind":"Name","value":"yearly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"gbp"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monthly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}},{"kind":"Field","name":{"kind":"Name","value":"yearly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"teamUnlimited"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monthly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}},{"kind":"Field","name":{"kind":"Name","value":"yearly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pro"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monthly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}},{"kind":"Field","name":{"kind":"Name","value":"yearly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"proUnlimited"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monthly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}},{"kind":"Field","name":{"kind":"Name","value":"yearly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateProjectModelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateProjectModel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateModelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"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"}}]}}]}}]}}]} as unknown as DocumentNode; export const FindProjectModelByNameDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindProjectModelByName"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelByName"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}}],"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"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/shared/src/authz/domain/authErrors.ts b/packages/shared/src/authz/domain/authErrors.ts index ff6eecc9b..15b03c157 100644 --- a/packages/shared/src/authz/domain/authErrors.ts +++ b/packages/shared/src/authz/domain/authErrors.ts @@ -116,7 +116,7 @@ export const WorkspaceLimitsReachedError = defineAuthError< message: 'Workspace limits have been reached' }) -export const WorkspaceNoFeatureAccessError = defineAuthError({ +export const WorkspacePlanNoFeatureAccessError = defineAuthError({ code: 'WorkspaceNoFeatureAccess', message: 'Your workspace plan does not have access to this feature.' }) diff --git a/packages/shared/src/authz/policies/index.ts b/packages/shared/src/authz/policies/index.ts index 71ade434c..def12f934 100644 --- a/packages/shared/src/authz/policies/index.ts +++ b/packages/shared/src/authz/policies/index.ts @@ -31,6 +31,7 @@ import { canLoadPolicy } from './project/canLoad.js' import { canReadMemberEmailPolicy } from './workspace/canReadMemberEmail.js' import { canCreateWorkspacePolicy } from './workspace/canCreateWorkspace.js' import { canUseWorkspacePlanFeature } from './workspace/canUseWorkspacePlanFeature.js' +import { canUpdateEmbedTokensPolicy } from './project/canUpdateEmbedTokens.js' export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ project: { @@ -68,7 +69,9 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ canLeave: canLeaveProjectPolicy(loaders), canInvite: canInviteToProjectPolicy(loaders), canPublish: canPublishPolicy(loaders), - canLoad: canLoadPolicy(loaders) + canLoad: canLoadPolicy(loaders), + canReadEmbedTokens: canUpdateEmbedTokensPolicy(loaders), + canUpdateEmbedTokens: canUpdateEmbedTokensPolicy(loaders) }, workspace: { canCreateProject: canCreateWorkspaceProjectPolicy(loaders), diff --git a/packages/shared/src/authz/policies/project/canUpdateEmbedTokens.spec.ts b/packages/shared/src/authz/policies/project/canUpdateEmbedTokens.spec.ts new file mode 100644 index 000000000..1ffeb6e04 --- /dev/null +++ b/packages/shared/src/authz/policies/project/canUpdateEmbedTokens.spec.ts @@ -0,0 +1,134 @@ +import cryptoRandomString from 'crypto-random-string' +import { Roles } from '../../../core/constants.js' +import { parseFeatureFlags } from '../../../environment/index.js' +import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js' +import { canUpdateEmbedTokensPolicy } from './canUpdateEmbedTokens.js' +import { assert, describe, expect, it } from 'vitest' +import { + ProjectNotEnoughPermissionsError, + ServerNoAccessError, + WorkspacePlanNoFeatureAccessError +} from '../../domain/authErrors.js' +import { OverridesOf } from '../../../tests/helpers/types.js' + +const buildCanUpdateEmbedTokens = ( + overrides?: OverridesOf +) => { + const workspaceId = cryptoRandomString({ length: 9 }) + + return canUpdateEmbedTokensPolicy({ + getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }), + getServerRole: async () => { + return Roles.Server.User + }, + getProject: getProjectFake({ + id: 'project-id', + workspaceId + }), + getProjectRole: async () => { + return Roles.Stream.Owner + }, + getWorkspace: getWorkspaceFake({ + id: workspaceId + }), + getWorkspaceRole: async () => { + return Roles.Workspace.Admin + }, + getWorkspaceSsoProvider: async () => { + return null + }, + getWorkspaceSsoSession: async () => { + assert.fail() + }, + getWorkspacePlan: async () => { + return { + status: 'valid', + workspaceId, + name: 'unlimited', + createdAt: new Date(), + updatedAt: new Date() + } + }, + ...overrides + }) +} + +const canUpdateEmbedTokensArgs = () => ({ + userId: cryptoRandomString({ length: 9 }), + projectId: cryptoRandomString({ length: 9 }) +}) + +describe('canUpdateEmbedTokensArgs returns a function, that', () => { + it('requires a user session', async () => { + const result = await buildCanUpdateEmbedTokens({ + getServerRole: async () => { + return null + } + })(canUpdateEmbedTokensArgs()) + + expect(result).toBeAuthErrorResult({ + code: ServerNoAccessError.code + }) + }) + it('requires user to be project owner', async () => { + const result = await buildCanUpdateEmbedTokens({ + getWorkspaceRole: async () => { + return Roles.Workspace.Member + }, + getProjectRole: async () => { + return Roles.Stream.Contributor + } + })(canUpdateEmbedTokensArgs()) + + expect(result).toBeAuthErrorResult({ + code: ProjectNotEnoughPermissionsError.code + }) + }) + it('does not check workspace plan if workspaces not enabled', async () => { + const result = await buildCanUpdateEmbedTokens({ + getEnv: async () => + parseFeatureFlags({ + FF_WORKSPACES_MODULE_ENABLED: 'false' + }), + getWorkspacePlan: async () => { + assert.fail() + } + })(canUpdateEmbedTokensArgs()) + + expect(result).toBeAuthOKResult() + }) + it('does not check workspace plan if project is not in a workspace', async () => { + const result = await buildCanUpdateEmbedTokens({ + getProject: getProjectFake({ + id: 'project-id', + workspaceId: null + }), + getWorkspacePlan: async () => { + assert.fail() + } + })(canUpdateEmbedTokensArgs()) + + expect(result).toBeAuthOKResult() + }) + it('requires a paid workspace plan, if project is in a workspace', async () => { + const result = await buildCanUpdateEmbedTokens({ + getWorkspacePlan: async () => { + return { + status: 'valid', + workspaceId: 'foo', + name: 'free', + createdAt: new Date(), + updatedAt: new Date() + } + } + })(canUpdateEmbedTokensArgs()) + + expect(result).toBeAuthErrorResult({ + code: WorkspacePlanNoFeatureAccessError.code + }) + }) + it('allows action on paid workspace plans', async () => { + const result = await buildCanUpdateEmbedTokens()(canUpdateEmbedTokensArgs()) + expect(result).toBeAuthOKResult() + }) +}) diff --git a/packages/shared/src/authz/policies/project/canUpdateEmbedTokens.ts b/packages/shared/src/authz/policies/project/canUpdateEmbedTokens.ts new file mode 100644 index 000000000..27521c429 --- /dev/null +++ b/packages/shared/src/authz/policies/project/canUpdateEmbedTokens.ts @@ -0,0 +1,101 @@ +import { err, ok } from 'true-myth/result' +import { + ProjectNoAccessError, + ProjectNotEnoughPermissionsError, + ProjectNotFoundError, + ServerNoAccessError, + ServerNoSessionError, + ServerNotEnoughPermissionsError, + WorkspaceNoAccessError, + WorkspacePlanNoFeatureAccessError, + WorkspaceNotEnoughPermissionsError, + WorkspaceSsoSessionNoAccessError +} from '../../domain/authErrors.js' +import { MaybeUserContext, ProjectContext } from '../../domain/context.js' +import { Loaders } from '../../domain/loaders.js' +import { AuthPolicy } from '../../domain/policies.js' +import { + ensureImplicitProjectMemberWithWriteAccessFragment, + ensureMinimumProjectRoleFragment +} from '../../fragments/projects.js' +import { Roles } from '../../../core/constants.js' + +type PolicyLoaderKeys = + | typeof Loaders.getEnv + | typeof Loaders.getServerRole + | typeof Loaders.getProject + | typeof Loaders.getWorkspaceRole + | typeof Loaders.getWorkspace + | typeof Loaders.getWorkspaceSsoProvider + | typeof Loaders.getWorkspaceSsoSession + | typeof Loaders.getProjectRole + | typeof Loaders.getWorkspacePlan + +type PolicyArgs = MaybeUserContext & ProjectContext + +type PolicyErrors = InstanceType< + | typeof ServerNoAccessError + | typeof ServerNoSessionError + | typeof ServerNotEnoughPermissionsError + | typeof ProjectNotFoundError + | typeof ProjectNoAccessError + | typeof WorkspaceNoAccessError + | typeof WorkspaceSsoSessionNoAccessError + | typeof ProjectNotEnoughPermissionsError + | typeof WorkspaceNotEnoughPermissionsError + | typeof WorkspacePlanNoFeatureAccessError +> + +export const canUpdateEmbedTokensPolicy: AuthPolicy< + PolicyLoaderKeys, + PolicyArgs, + PolicyErrors +> = + (loaders) => + async ({ userId, projectId }) => { + const env = await loaders.getEnv() + const project = await loaders.getProject({ projectId }) + + if (!!project?.workspaceId && env.FF_WORKSPACES_MODULE_ENABLED) { + // Ensure owner-level access and valid plan + const ensuredProjectRole = + await ensureImplicitProjectMemberWithWriteAccessFragment(loaders)({ + userId, + projectId, + role: Roles.Stream.Owner + }) + if (ensuredProjectRole.isErr) { + return err(ensuredProjectRole.error) + } + + const plan = await loaders.getWorkspacePlan({ workspaceId: project.workspaceId }) + + switch (plan?.name) { + case 'academia': + case 'enterprise': + case 'pro': + case 'proUnlimited': + case 'proUnlimitedInvoiced': + case 'team': + case 'teamUnlimited': + case 'teamUnlimitedInvoiced': + case 'unlimited': + return ok() + case 'free': + default: + return err(new WorkspacePlanNoFeatureAccessError()) + } + } else { + // Ensure project owner + const isProjectOwner = await ensureMinimumProjectRoleFragment(loaders)({ + userId: userId!, + projectId, + role: Roles.Stream.Owner + }) + if (isProjectOwner.isErr) { + return err(isProjectOwner.error) + } + + return ok() + } + } diff --git a/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.spec.ts b/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.spec.ts index 26d82e8a1..bb8b7b47c 100644 --- a/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.spec.ts +++ b/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.spec.ts @@ -10,7 +10,7 @@ import { ServerNoSessionError, ServerNotEnoughPermissionsError, WorkspaceNoAccessError, - WorkspaceNoFeatureAccessError, + WorkspacePlanNoFeatureAccessError, WorkspaceNotEnoughPermissionsError, WorkspaceReadOnlyError } from '../../domain/authErrors.js' @@ -151,7 +151,7 @@ describe('canUseFeature', () => { }) expect(result).toBeAuthErrorResult({ - code: WorkspaceNoFeatureAccessError.code + code: WorkspacePlanNoFeatureAccessError.code }) }) diff --git a/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.ts b/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.ts index 230a164f5..5a093d014 100644 --- a/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.ts +++ b/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.ts @@ -4,7 +4,7 @@ import { ServerNoSessionError, ServerNotEnoughPermissionsError, WorkspaceNoAccessError, - WorkspaceNoFeatureAccessError, + WorkspacePlanNoFeatureAccessError, WorkspaceNotEnoughPermissionsError, WorkspaceReadOnlyError, WorkspacesNotEnabledError, @@ -43,7 +43,7 @@ type PolicyErrors = | InstanceType | InstanceType | InstanceType - | InstanceType + | InstanceType export const canUseWorkspacePlanFeature: AuthPolicy< PolicyLoaderKeys, @@ -63,10 +63,10 @@ export const canUseWorkspacePlanFeature: AuthPolicy< if (ensuredNotReadOnly.isErr) return err(ensuredNotReadOnly.error) const workspacePlan = await loaders.getWorkspacePlan({ workspaceId }) - if (!workspacePlan) return err(new WorkspaceNoFeatureAccessError()) + if (!workspacePlan) return err(new WorkspacePlanNoFeatureAccessError()) const canUseFeature = workspacePlanHasAccessToFeature({ plan: workspacePlan.name, feature }) - return canUseFeature ? ok() : err(new WorkspaceNoFeatureAccessError()) + return canUseFeature ? ok() : err(new WorkspacePlanNoFeatureAccessError()) }