feat(tokens): create embed-specific tokens (#5013)
* feat(tokens): create embed-specific tokens * fix(tokens): repo functions and policy sketch * chore(authz): embed token policies and tests * chore(authz): fine * chore(gql): lint descriptions * fix(embedTokens): better api surface, repo structure * chore(embedTokens): test fixes * fix(embeds): check resource access * fix(embeds): use resource access util
This commit is contained in:
@@ -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<Scalars['BigInt']['input']>;
|
||||
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<Scalars['String']['output']>;
|
||||
/** Public project-level configuration for embedded viewer */
|
||||
embedOptions: ProjectEmbedOptions;
|
||||
embedTokens: Array<EmbedToken>;
|
||||
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,
|
||||
|
||||
@@ -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!]!
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ApiToken,
|
||||
EmbedApiToken,
|
||||
PersonalApiToken,
|
||||
TokenResourceAccessDefinition,
|
||||
TokenResourceIdentifierType,
|
||||
@@ -31,6 +32,8 @@ export type StorePersonalApiToken = (
|
||||
token: PersonalApiToken
|
||||
) => Promise<PersonalApiToken>
|
||||
|
||||
export type StoreEmbedApiToken = (token: EmbedApiToken) => Promise<EmbedApiToken>
|
||||
|
||||
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<boolean>
|
||||
|
||||
export type RevokeUserTokenById = (tokenId: string, userId: string) => Promise<boolean>
|
||||
|
||||
export type RevokeEmbedTokenById = (args: {
|
||||
tokenId: string
|
||||
projectId: string
|
||||
}) => Promise<boolean>
|
||||
|
||||
export type RevokeProjectEmbedTokens = (args: { projectId: string }) => Promise<void>
|
||||
|
||||
export type GetApiTokenById = (tokenId: string) => Promise<Optional<ApiToken>>
|
||||
|
||||
export type GetTokenScopesById = (tokenId: string) => Promise<TokenScope[]>
|
||||
@@ -86,4 +104,18 @@ export type CreateAndStorePersonalAccessToken = (
|
||||
lifespan?: number | bigint
|
||||
) => Promise<string>
|
||||
|
||||
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<TokenValidationResult>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Scalars['String']['input']>;
|
||||
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<LimitedUser>;
|
||||
};
|
||||
|
||||
export type EmbedTokenCreateInput = {
|
||||
lifespan?: InputMaybe<Scalars['BigInt']['input']>;
|
||||
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<Scalars['String']['output']>;
|
||||
/** Public project-level configuration for embedded viewer */
|
||||
embedOptions: ProjectEmbedOptions;
|
||||
embedTokens: Array<EmbedToken>;
|
||||
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<Omit<CreateEmbedTokenReturn, 'tokenMetadata'> & { tokenMetadata: ResolversTypes['EmbedToken'] }>;
|
||||
CreateModelInput: CreateModelInput;
|
||||
CreateServerRegionInput: CreateServerRegionInput;
|
||||
CreateUserEmailInput: CreateUserEmailInput;
|
||||
@@ -5444,6 +5490,8 @@ export type ResolversTypes = {
|
||||
DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput;
|
||||
EditCommentInput: EditCommentInput;
|
||||
EmailVerificationRequestInput: EmailVerificationRequestInput;
|
||||
EmbedToken: ResolverTypeWrapper<EmbedTokenGraphQLReturn>;
|
||||
EmbedTokenCreateInput: EmbedTokenCreateInput;
|
||||
FileUpload: ResolverTypeWrapper<FileUploadGraphQLReturn>;
|
||||
FileUploadCollection: ResolverTypeWrapper<Omit<FileUploadCollection, 'items'> & { items: Array<ResolversTypes['FileUpload']> }>;
|
||||
FileUploadMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
|
||||
@@ -5773,6 +5821,7 @@ export type ResolversParentTypes = {
|
||||
CreateAutomateFunctionWithoutVersionInput: CreateAutomateFunctionWithoutVersionInput;
|
||||
CreateCommentInput: CreateCommentInput;
|
||||
CreateCommentReplyInput: CreateCommentReplyInput;
|
||||
CreateEmbedTokenReturn: Omit<CreateEmbedTokenReturn, 'tokenMetadata'> & { 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<FileUploadCollection, 'items'> & { items: Array<ResolversParentTypes['FileUpload']> };
|
||||
FileUploadMutations: MutationsObjectGraphQLReturn;
|
||||
@@ -6458,6 +6509,12 @@ export type CountOnlyCollectionResolvers<ContextType = GraphQLContext, ParentTyp
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type CreateEmbedTokenReturnResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['CreateEmbedTokenReturn'] = ResolversParentTypes['CreateEmbedTokenReturn']> = {
|
||||
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
tokenMetadata?: Resolver<ResolversTypes['EmbedToken'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type CurrencyBasedPricesResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['CurrencyBasedPrices'] = ResolversParentTypes['CurrencyBasedPrices']> = {
|
||||
gbp?: Resolver<ResolversTypes['WorkspacePaidPlanPrices'], ParentType, ContextType>;
|
||||
usd?: Resolver<ResolversTypes['WorkspacePaidPlanPrices'], ParentType, ContextType>;
|
||||
@@ -6468,6 +6525,17 @@ export interface DateTimeScalarConfig extends GraphQLScalarTypeConfig<ResolversT
|
||||
name: 'DateTime';
|
||||
}
|
||||
|
||||
export type EmbedTokenResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['EmbedToken'] = ResolversParentTypes['EmbedToken']> = {
|
||||
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
lastUsed?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
lifespan?: Resolver<ResolversTypes['BigInt'], ParentType, ContextType>;
|
||||
projectId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
resourceIdString?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
tokenId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
user?: Resolver<Maybe<ResolversTypes['LimitedUser']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type FileUploadResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['FileUpload'] = ResolversParentTypes['FileUpload']> = {
|
||||
branchName?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
convertedCommitId?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
@@ -6820,6 +6888,7 @@ export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends Re
|
||||
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
embedOptions?: Resolver<ResolversTypes['ProjectEmbedOptions'], ParentType, ContextType>;
|
||||
embedTokens?: Resolver<Array<ResolversTypes['EmbedToken']>, ParentType, ContextType>;
|
||||
hasAccessToFeature?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectHasAccessToFeatureArgs, 'featureName'>>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
invitableCollaborators?: Resolver<ResolversTypes['WorkspaceCollaboratorCollection'], ParentType, ContextType, RequireFields<ProjectInvitableCollaboratorsArgs, 'limit'>>;
|
||||
@@ -6954,10 +7023,13 @@ export type ProjectMutationsResolvers<ContextType = GraphQLContext, ParentType e
|
||||
automationMutations?: Resolver<ResolversTypes['ProjectAutomationMutations'], ParentType, ContextType, RequireFields<ProjectMutationsAutomationMutationsArgs, 'projectId'>>;
|
||||
batchDelete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectMutationsBatchDeleteArgs, 'ids'>>;
|
||||
create?: Resolver<ResolversTypes['Project'], ParentType, ContextType, Partial<ProjectMutationsCreateArgs>>;
|
||||
createEmbedToken?: Resolver<ResolversTypes['CreateEmbedTokenReturn'], ParentType, ContextType, RequireFields<ProjectMutationsCreateEmbedTokenArgs, 'token'>>;
|
||||
createForOnboarding?: Resolver<ResolversTypes['Project'], ParentType, ContextType>;
|
||||
delete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectMutationsDeleteArgs, 'id'>>;
|
||||
invites?: Resolver<ResolversTypes['ProjectInviteMutations'], ParentType, ContextType>;
|
||||
leave?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectMutationsLeaveArgs, 'id'>>;
|
||||
revokeEmbedToken?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectMutationsRevokeEmbedTokenArgs, 'projectId' | 'token'>>;
|
||||
revokeEmbedTokens?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectMutationsRevokeEmbedTokensArgs, 'projectId'>>;
|
||||
update?: Resolver<ResolversTypes['Project'], ParentType, ContextType, RequireFields<ProjectMutationsUpdateArgs, 'update'>>;
|
||||
updateRole?: Resolver<ResolversTypes['Project'], ParentType, ContextType, RequireFields<ProjectMutationsUpdateRoleArgs, 'input'>>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
@@ -7911,8 +7983,10 @@ export type Resolvers<ContextType = GraphQLContext> = {
|
||||
Commit?: CommitResolvers<ContextType>;
|
||||
CommitCollection?: CommitCollectionResolvers<ContextType>;
|
||||
CountOnlyCollection?: CountOnlyCollectionResolvers<ContextType>;
|
||||
CreateEmbedTokenReturn?: CreateEmbedTokenReturnResolvers<ContextType>;
|
||||
CurrencyBasedPrices?: CurrencyBasedPricesResolvers<ContextType>;
|
||||
DateTime?: GraphQLScalarType;
|
||||
EmbedToken?: EmbedTokenResolvers<ContextType>;
|
||||
FileUpload?: FileUploadResolvers<ContextType>;
|
||||
FileUploadCollection?: FileUploadCollectionResolvers<ContextType>;
|
||||
FileUploadMutations?: FileUploadMutationsResolvers<ContextType>;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Knex } from 'knex'
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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<void> {
|
||||
await knex.schema.dropTableIfExists('embed_api_tokens')
|
||||
}
|
||||
@@ -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<ApiTokenRecord>(ApiTokens.name),
|
||||
embedApiTokens: (db: Knex) => db<EmbedApiTokenRecord>(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<ApiTokenRecord, 'createdAt' | 'lastUsed' | 'lifespan'>)[]
|
||||
}
|
||||
|
||||
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<EmbedApiTokenRecord>(EmbedApiTokens.name)
|
||||
.where('projectId', projectId)
|
||||
})
|
||||
.delete()
|
||||
}
|
||||
@@ -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<TokenResourceAccessRecord>(TokenResourceAccess.name),
|
||||
userServerAppTokens: (db: Knex) =>
|
||||
db<UserServerAppTokenRecord>(UserServerAppTokens.name),
|
||||
personalApiTokens: (db: Knex) => db<PersonalApiTokenRecord>(PersonalApiTokens.name)
|
||||
personalApiTokens: (db: Knex) => db<PersonalApiTokenRecord>(PersonalApiTokens.name),
|
||||
embedApiTokens: (db: Knex) => db<EmbedApiTokenRecord>(EmbedApiTokens.name)
|
||||
}
|
||||
|
||||
export const storeApiTokenFactory =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<Scalars['String']['input']>;
|
||||
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<LimitedUser>;
|
||||
};
|
||||
|
||||
export type EmbedTokenCreateInput = {
|
||||
lifespan?: InputMaybe<Scalars['BigInt']['input']>;
|
||||
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<Scalars['String']['output']>;
|
||||
/** Public project-level configuration for embedded viewer */
|
||||
embedOptions: ProjectEmbedOptions;
|
||||
embedTokens: Array<EmbedToken>;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const createEmbedTokenMutation = gql`
|
||||
mutation CreateEmbedToken($token: EmbedTokenCreateInput!) {
|
||||
projectMutations {
|
||||
createEmbedToken(token: $token) {
|
||||
token
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
File diff suppressed because one or more lines are too long
@@ -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.'
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<typeof canUpdateEmbedTokensPolicy>
|
||||
) => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ServerNoSessionError,
|
||||
ServerNotEnoughPermissionsError,
|
||||
WorkspaceNoAccessError,
|
||||
WorkspaceNoFeatureAccessError,
|
||||
WorkspacePlanNoFeatureAccessError,
|
||||
WorkspaceNotEnoughPermissionsError,
|
||||
WorkspaceReadOnlyError,
|
||||
WorkspacesNotEnabledError,
|
||||
@@ -43,7 +43,7 @@ type PolicyErrors =
|
||||
| InstanceType<typeof ServerNoAccessError>
|
||||
| InstanceType<typeof ServerNotEnoughPermissionsError>
|
||||
| InstanceType<typeof WorkspaceNotEnoughPermissionsError>
|
||||
| InstanceType<typeof WorkspaceNoFeatureAccessError>
|
||||
| InstanceType<typeof WorkspacePlanNoFeatureAccessError>
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user