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:
Chuck Driesler
2025-07-07 12:02:38 +01:00
committed by GitHub
parent 3e7e11b8a1
commit aa29a09ebc
25 changed files with 940 additions and 12 deletions
@@ -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!]!
}
+1
View File
@@ -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
+7
View File
@@ -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.'
})
+4 -1
View File
@@ -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())
}