diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index f8cc794a8..e39bc8f55 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -3716,6 +3716,23 @@ export type SavedViewGroupPermissionChecks = { canUpdate: PermissionCheckResult; }; +export type SavedViewGroupToken = { + __typename?: 'SavedViewGroupToken'; + createdAt: Scalars['DateTime']['output']; + lastUsed: Scalars['DateTime']['output']; + lifespan: Scalars['BigInt']['output']; + projects: Array; + savedViewGroupId: Scalars['String']['output']; + tokenId: Scalars['String']['output']; + user?: Maybe; +}; + +export type SavedViewGroupTokenReturn = { + __typename?: 'SavedViewGroupTokenReturn'; + token: Scalars['String']['output']; + tokenMetadata: SavedViewGroupToken; +}; + export type SavedViewGroupViewsInput = { cursor?: InputMaybe; limit?: InputMaybe; @@ -3750,6 +3767,7 @@ export type SavedViewGroupsInput = { export type SavedViewMutations = { __typename?: 'SavedViewMutations'; createGroup: SavedViewGroup; + createToken: SavedViewGroupTokenReturn; createView: SavedView; deleteGroup: Scalars['Boolean']['output']; deleteView: Scalars['Boolean']['output']; @@ -3763,6 +3781,11 @@ export type SavedViewMutationsCreateGroupArgs = { }; +export type SavedViewMutationsCreateTokenArgs = { + groupId: Scalars['String']['input']; +}; + + export type SavedViewMutationsCreateViewArgs = { input: CreateSavedViewInput; }; @@ -9196,6 +9219,8 @@ export type AllObjectTypes = { SavedViewGroup: SavedViewGroup, SavedViewGroupCollection: SavedViewGroupCollection, SavedViewGroupPermissionChecks: SavedViewGroupPermissionChecks, + SavedViewGroupToken: SavedViewGroupToken, + SavedViewGroupTokenReturn: SavedViewGroupTokenReturn, SavedViewMutations: SavedViewMutations, SavedViewPermissionChecks: SavedViewPermissionChecks, Scope: Scope, @@ -10315,8 +10340,22 @@ export type SavedViewGroupCollectionFieldArgs = { export type SavedViewGroupPermissionChecksFieldArgs = { canUpdate: {}, } +export type SavedViewGroupTokenFieldArgs = { + createdAt: {}, + lastUsed: {}, + lifespan: {}, + projects: {}, + savedViewGroupId: {}, + tokenId: {}, + user: {}, +} +export type SavedViewGroupTokenReturnFieldArgs = { + token: {}, + tokenMetadata: {}, +} export type SavedViewMutationsFieldArgs = { createGroup: SavedViewMutationsCreateGroupArgs, + createToken: SavedViewMutationsCreateTokenArgs, createView: SavedViewMutationsCreateViewArgs, deleteGroup: SavedViewMutationsDeleteGroupArgs, deleteView: SavedViewMutationsDeleteViewArgs, @@ -11072,6 +11111,8 @@ export type AllObjectFieldArgTypes = { SavedViewGroup: SavedViewGroupFieldArgs, SavedViewGroupCollection: SavedViewGroupCollectionFieldArgs, SavedViewGroupPermissionChecks: SavedViewGroupPermissionChecksFieldArgs, + SavedViewGroupToken: SavedViewGroupTokenFieldArgs, + SavedViewGroupTokenReturn: SavedViewGroupTokenReturnFieldArgs, SavedViewMutations: SavedViewMutationsFieldArgs, SavedViewPermissionChecks: SavedViewPermissionChecksFieldArgs, Scope: ScopeFieldArgs, diff --git a/packages/server/assets/viewer/typedefs/permissions.graphql b/packages/server/assets/viewer/typedefs/permissions.graphql index 1618db326..200140151 100644 --- a/packages/server/assets/viewer/typedefs/permissions.graphql +++ b/packages/server/assets/viewer/typedefs/permissions.graphql @@ -18,6 +18,7 @@ extend type SavedView { } type SavedViewGroupPermissionChecks { + canCreateToken: PermissionCheckResult! canUpdate: PermissionCheckResult! } diff --git a/packages/server/assets/viewer/typedefs/shares.graphql b/packages/server/assets/viewer/typedefs/shares.graphql new file mode 100644 index 000000000..652da125f --- /dev/null +++ b/packages/server/assets/viewer/typedefs/shares.graphql @@ -0,0 +1,31 @@ +extend type SavedViewGroup { + shareLink: SavedViewGroupShareLink +} + +type SavedViewGroupShareLink { + id: ID! + # going to ignore this in the API for now, its in the DB + # createdBy: LimitedUser + validUntil: DateTime! + createdAt: DateTime! + content: String! + revoked: Boolean! +} + +input SavedViewGroupShareInput { + groupId: ID! + projectId: ID! +} + +input SavedViewGroupShareUpdateInput { + groupId: ID! + projectId: ID! + shareId: ID! +} + +extend type SavedViewMutations { + share(input: SavedViewGroupShareInput!): SavedViewGroupShareLink! + deleteShare(input: SavedViewGroupShareUpdateInput!): Boolean! + disableShare(input: SavedViewGroupShareUpdateInput!): SavedViewGroupShareLink! + enableShare(input: SavedViewGroupShareUpdateInput!): SavedViewGroupShareLink! +} diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 76fdd92f0..c1ba6777c 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -3722,6 +3722,7 @@ export type SavedViewGroup = { projectId: Scalars['ID']['output']; /** Resources that were used to find this group */ resourceIds: Array; + shareLink?: Maybe; title: Scalars['String']['output']; views: SavedViewCollection; }; @@ -3740,9 +3741,30 @@ export type SavedViewGroupCollection = { export type SavedViewGroupPermissionChecks = { __typename?: 'SavedViewGroupPermissionChecks'; + canCreateToken: PermissionCheckResult; canUpdate: PermissionCheckResult; }; +export type SavedViewGroupShareInput = { + groupId: Scalars['ID']['input']; + projectId: Scalars['ID']['input']; +}; + +export type SavedViewGroupShareLink = { + __typename?: 'SavedViewGroupShareLink'; + content: Scalars['String']['output']; + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + revoked: Scalars['Boolean']['output']; + validUntil: Scalars['DateTime']['output']; +}; + +export type SavedViewGroupShareUpdateInput = { + groupId: Scalars['ID']['input']; + projectId: Scalars['ID']['input']; + shareId: Scalars['ID']['input']; +}; + export type SavedViewGroupViewsInput = { cursor?: InputMaybe; limit?: InputMaybe; @@ -3779,7 +3801,11 @@ export type SavedViewMutations = { createGroup: SavedViewGroup; createView: SavedView; deleteGroup: Scalars['Boolean']['output']; + deleteShare: Scalars['Boolean']['output']; deleteView: Scalars['Boolean']['output']; + disableShare: SavedViewGroupShareLink; + enableShare: SavedViewGroupShareLink; + share: SavedViewGroupShareLink; updateGroup: SavedViewGroup; updateView: SavedView; }; @@ -3800,11 +3826,31 @@ export type SavedViewMutationsDeleteGroupArgs = { }; +export type SavedViewMutationsDeleteShareArgs = { + input: SavedViewGroupShareUpdateInput; +}; + + export type SavedViewMutationsDeleteViewArgs = { input: DeleteSavedViewInput; }; +export type SavedViewMutationsDisableShareArgs = { + input: SavedViewGroupShareUpdateInput; +}; + + +export type SavedViewMutationsEnableShareArgs = { + input: SavedViewGroupShareUpdateInput; +}; + + +export type SavedViewMutationsShareArgs = { + input: SavedViewGroupShareInput; +}; + + export type SavedViewMutationsUpdateGroupArgs = { input: UpdateSavedViewGroupInput; }; @@ -6406,6 +6452,9 @@ export type ResolversTypes = { SavedViewGroup: ResolverTypeWrapper; SavedViewGroupCollection: ResolverTypeWrapper & { items: Array }>; SavedViewGroupPermissionChecks: ResolverTypeWrapper; + SavedViewGroupShareInput: SavedViewGroupShareInput; + SavedViewGroupShareLink: ResolverTypeWrapper; + SavedViewGroupShareUpdateInput: SavedViewGroupShareUpdateInput; SavedViewGroupViewsInput: SavedViewGroupViewsInput; SavedViewGroupsInput: SavedViewGroupsInput; SavedViewMutations: ResolverTypeWrapper; @@ -6788,6 +6837,9 @@ export type ResolversParentTypes = { SavedViewGroup: SavedViewGroupGraphQLReturn; SavedViewGroupCollection: Omit & { items: Array }; SavedViewGroupPermissionChecks: SavedViewGroupPermissionChecksGraphQLReturn; + SavedViewGroupShareInput: SavedViewGroupShareInput; + SavedViewGroupShareLink: SavedViewGroupShareLink; + SavedViewGroupShareUpdateInput: SavedViewGroupShareUpdateInput; SavedViewGroupViewsInput: SavedViewGroupViewsInput; SavedViewGroupsInput: SavedViewGroupsInput; SavedViewMutations: MutationsObjectGraphQLReturn; @@ -8246,6 +8298,7 @@ export type SavedViewGroupResolvers; projectId?: Resolver; resourceIds?: Resolver, ParentType, ContextType>; + shareLink?: Resolver, ParentType, ContextType>; title?: Resolver; views?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; @@ -8259,15 +8312,29 @@ export type SavedViewGroupCollectionResolvers = { + canCreateToken?: Resolver; canUpdate?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; +export type SavedViewGroupShareLinkResolvers = { + content?: Resolver; + createdAt?: Resolver; + id?: Resolver; + revoked?: Resolver; + validUntil?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type SavedViewMutationsResolvers = { createGroup?: Resolver>; createView?: Resolver>; deleteGroup?: Resolver>; + deleteShare?: Resolver>; deleteView?: Resolver>; + disableShare?: Resolver>; + enableShare?: Resolver>; + share?: Resolver>; updateGroup?: Resolver>; updateView?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; @@ -9192,6 +9259,7 @@ export type Resolvers = { SavedViewGroup?: SavedViewGroupResolvers; SavedViewGroupCollection?: SavedViewGroupCollectionResolvers; SavedViewGroupPermissionChecks?: SavedViewGroupPermissionChecksResolvers; + SavedViewGroupShareLink?: SavedViewGroupShareLinkResolvers; SavedViewMutations?: SavedViewMutationsResolvers; SavedViewPermissionChecks?: SavedViewPermissionChecksResolvers; Scope?: ScopeResolvers; diff --git a/packages/server/modules/shared/helpers/errorHelper.ts b/packages/server/modules/shared/helpers/errorHelper.ts index a3a17c7d1..909fe65cf 100644 --- a/packages/server/modules/shared/helpers/errorHelper.ts +++ b/packages/server/modules/shared/helpers/errorHelper.ts @@ -78,7 +78,6 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => { return new NotFoundError(e.message) case Authz.PersonalProjectsLimitedError.code: case Authz.UngroupedSavedViewGroupLockError.code: - return new BadRequestError(e.message) case Authz.DashboardNoProjectsError.code: return new BadRequestError(e.message) default: diff --git a/packages/server/modules/viewer/domain/operations/savedViewGroupApiTokens.ts b/packages/server/modules/viewer/domain/operations/savedViewGroupApiTokens.ts new file mode 100644 index 000000000..ee09bd3b2 --- /dev/null +++ b/packages/server/modules/viewer/domain/operations/savedViewGroupApiTokens.ts @@ -0,0 +1,23 @@ +import type { + SavedViewGroupApiToken, + SavedViewGroupApiTokenRecord +} from '@/modules/viewer/domain/types/savedViewGroupApiTokens' +import type { Exact } from 'type-fest' + +export type StoreSavedViewGroupApiToken = < + T extends Exact +>( + token: T +) => Promise + +export type DeleteSavedViewGroupApiToken = (args: { + tokenId: string +}) => Promise + +export type GetSavedViewGroupApiTokens = (args: { + savedViewGroupId: string +}) => Promise + +export type GetSavedViewGroupApiToken = (args: { + savedViewGroupId: string +}) => Promise diff --git a/packages/server/modules/viewer/domain/types/savedViewGroupApiTokens.ts b/packages/server/modules/viewer/domain/types/savedViewGroupApiTokens.ts new file mode 100644 index 000000000..a00ec9e17 --- /dev/null +++ b/packages/server/modules/viewer/domain/types/savedViewGroupApiTokens.ts @@ -0,0 +1,14 @@ +export type SavedViewGroupApiTokenRecord = { + tokenId: string + projectId: string + savedViewGroupId: string + userId: string + content: string +} + +export type SavedViewGroupApiToken = SavedViewGroupApiTokenRecord & { + createdAt: Date + lastUsed: Date + lifespan: number | bigint + revoked: boolean +} diff --git a/packages/server/modules/viewer/graph/resolvers/permissions.ts b/packages/server/modules/viewer/graph/resolvers/permissions.ts index 9f02ef2d7..78a10e71e 100644 --- a/packages/server/modules/viewer/graph/resolvers/permissions.ts +++ b/packages/server/modules/viewer/graph/resolvers/permissions.ts @@ -63,6 +63,15 @@ const resolvers: Resolvers = { } }, SavedViewGroupPermissionChecks: { + canCreateToken: async (parent, _args, ctx) => { + const savedViewGroupId = parent.savedViewGroup.id + const authResult = await ctx.authPolicies.project.savedViews.canUpdateGroup({ + userId: ctx.userId, + projectId: parent.savedViewGroup.projectId, + savedViewGroupId + }) + return toGraphqlResult(authResult) + }, canUpdate: async (parent, _args, ctx) => { const savedViewGroupId = parent.savedViewGroup.id const canUpdate = await ctx.authPolicies.project.savedViews.canUpdateGroup({ diff --git a/packages/server/modules/viewer/graph/resolvers/shares.ts b/packages/server/modules/viewer/graph/resolvers/shares.ts new file mode 100644 index 000000000..d2cbec4cf --- /dev/null +++ b/packages/server/modules/viewer/graph/resolvers/shares.ts @@ -0,0 +1,127 @@ +import type { Resolvers } from '@/modules/core/graph/generated/graphql' +import { db } from '@/db/knex' +import { DashboardMalformedTokenError } from '@/modules/dashboards/errors/dashboards' +import { createTokenFactory } from '@/modules/core/services/tokens' +import { + getApiTokenByIdFactory, + storeApiTokenFactory, + storeTokenResourceAccessDefinitionsFactory, + storeTokenScopesFactory, + updateApiTokenFactory +} from '@/modules/core/repositories/tokens' +import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper' +import dayjs from 'dayjs' +import type { SavedViewGroupApiToken } from '@/modules/viewer/domain/types/savedViewGroupApiTokens' +import { + deleteSavedViewGroupApiTokenFactory, + getSavedViewGroupApiTokenFactory, + storeSavedViewGroupApiTokenFactory +} from '@/modules/viewer/repositories/tokens' +import { createSavedViewGroupTokenFactory } from '@/modules/viewer/services/tokens' +import { getSavedViewGroupFactory } from '@/modules/viewer/repositories/dataLoaders/savedViews' + +const formatSavedGroupViewTokenToSavedViewGroupShare = ( + token: SavedViewGroupApiToken +) => { + return { + ...token, + id: token.tokenId, + validUntil: dayjs(token.createdAt) + .add(Number(token.lifespan), 'milliseconds') + .toDate() + } +} + +const resolvers: Resolvers = { + SavedViewGroup: { + shareLink: async (parent) => { + const token = await getSavedViewGroupApiTokenFactory({ db })({ + savedViewGroupId: parent.id + }) + if (!token) return null + + return formatSavedGroupViewTokenToSavedViewGroupShare(token) + } + }, + SavedViewMutations: { + share: async (_, { input }, ctx) => { + const authResult = await ctx.authPolicies.project.savedViews.canCreateToken({ + userId: ctx.userId, + projectId: input.projectId, + savedViewGroupId: input.groupId + }) + + throwIfAuthNotOk(authResult) + const existingToken = await getSavedViewGroupApiTokenFactory({ db })({ + savedViewGroupId: input.groupId + }) + + if (existingToken) { + return formatSavedGroupViewTokenToSavedViewGroupShare(existingToken) + } + + const token = await createSavedViewGroupTokenFactory({ + getSavedViewGroup: getSavedViewGroupFactory({ loaders: ctx.loaders }), + createToken: createTokenFactory({ + storeApiToken: storeApiTokenFactory({ db }), + storeTokenScopes: storeTokenScopesFactory({ db }), + storeTokenResourceAccessDefinitions: + storeTokenResourceAccessDefinitionsFactory({ + db + }) + }), + getToken: getApiTokenByIdFactory({ db }), + storeSavedViewGroupApiToken: storeSavedViewGroupApiTokenFactory({ db }) + })({ + projectId: input.projectId, + savedViewGroupId: input.groupId, + userId: ctx.userId! + }) + return formatSavedGroupViewTokenToSavedViewGroupShare(token.tokenMetadata) + }, + disableShare: async (_, { input }, ctx) => { + const authResult = await ctx.authPolicies.project.savedViews.canCreateToken({ + userId: ctx.userId, + projectId: input.projectId, + savedViewGroupId: input.groupId + }) + throwIfAuthNotOk(authResult) + const token = await getSavedViewGroupApiTokenFactory({ db })({ + savedViewGroupId: input.groupId + }) + if (!token) throw new DashboardMalformedTokenError() + await updateApiTokenFactory({ db })(input.shareId, { revoked: true }) + return formatSavedGroupViewTokenToSavedViewGroupShare(token) + }, + enableShare: async (_, { input }, ctx) => { + const authResult = await ctx.authPolicies.project.savedViews.canCreateToken({ + userId: ctx.userId, + projectId: input.projectId, + savedViewGroupId: input.groupId + }) + throwIfAuthNotOk(authResult) + await updateApiTokenFactory({ db })(input.shareId, { revoked: false }) + const token = await getSavedViewGroupApiTokenFactory({ db })({ + savedViewGroupId: input.groupId + }) + if (!token) throw new DashboardMalformedTokenError() + return formatSavedGroupViewTokenToSavedViewGroupShare(token) + }, + deleteShare: async (_, { input }, ctx) => { + const authResult = await ctx.authPolicies.project.savedViews.canCreateToken({ + userId: ctx.userId, + projectId: input.projectId, + savedViewGroupId: input.groupId + }) + throwIfAuthNotOk(authResult) + await deleteSavedViewGroupApiTokenFactory({ + db + })({ + tokenId: input.shareId + }) + return true + } + } +} + +export default resolvers diff --git a/packages/server/modules/viewer/migrations/20250923100148_add_saved_view_groups_shares_table.ts b/packages/server/modules/viewer/migrations/20250923100148_add_saved_view_groups_shares_table.ts new file mode 100644 index 000000000..3b6691589 --- /dev/null +++ b/packages/server/modules/viewer/migrations/20250923100148_add_saved_view_groups_shares_table.ts @@ -0,0 +1,31 @@ +import type { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('saved_view_group_api_tokens', (table) => { + table + .string('tokenId') + .notNullable() + .references('id') + .inTable('api_tokens') + .onDelete('cascade') + table.string('savedViewGroupId').notNullable() // can't be a fk due to multiregionality + table + .string('projectId') + .notNullable() + .references('id') + .inTable('streams') + .onDelete('cascade') + table + .string('userId') + .notNullable() + .references('id') + .inTable('users') + .onDelete('cascade') + table.string('content').notNullable() + table.primary(['savedViewGroupId', 'tokenId']) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('saved_view_group_api_tokens') +} diff --git a/packages/server/modules/viewer/repositories/tokens.ts b/packages/server/modules/viewer/repositories/tokens.ts new file mode 100644 index 000000000..fbd767553 --- /dev/null +++ b/packages/server/modules/viewer/repositories/tokens.ts @@ -0,0 +1,84 @@ +import { buildTableHelper } from '@/modules/core/dbSchema' +import { ApiTokens } from '@/modules/core/dbSchema' +import type { + DeleteSavedViewGroupApiToken, + GetSavedViewGroupApiToken, + GetSavedViewGroupApiTokens, + StoreSavedViewGroupApiToken +} from '@/modules/viewer/domain/operations/savedViewGroupApiTokens' +import type { + SavedViewGroupApiToken, + SavedViewGroupApiTokenRecord +} from '@/modules/viewer/domain/types/savedViewGroupApiTokens' +import type { Knex } from 'knex' + +export const SavedViewGroupApiTokens = buildTableHelper('saved_view_group_api_tokens', [ + 'tokenId', + 'projectId', + 'savedViewGroupId', + 'userId', + 'content' +]) + +const tables = { + savedGroupApiTokens: (db: Knex) => + db(SavedViewGroupApiTokens.name) +} + +export const storeSavedViewGroupApiTokenFactory = + (deps: { db: Knex }): StoreSavedViewGroupApiToken => + async (token) => { + const [newToken] = await tables + .savedGroupApiTokens(deps.db) + .insert(token) + .returning('*') + return newToken + } + +export const deleteSavedViewGroupApiTokenFactory = + (deps: { db: Knex }): DeleteSavedViewGroupApiToken => + async ({ tokenId }) => { + const [deletedToken] = await tables + .savedGroupApiTokens(deps.db) + .where({ tokenId }) + .del() + .returning('*') + return deletedToken + } + +export const getSavedViewGroupApiTokensFactory = + (deps: { db: Knex }): GetSavedViewGroupApiTokens => + async ({ savedViewGroupId }) => { + const tokens = await tables + .savedGroupApiTokens(deps.db) + .orderBy(ApiTokens.col.createdAt) + .join(ApiTokens.name, ApiTokens.col.id, SavedViewGroupApiTokens.col.tokenId) + .select([ + ...SavedViewGroupApiTokens.cols, + ApiTokens.col.createdAt, + ApiTokens.col.lastUsed, + ApiTokens.col.lifespan, + ApiTokens.col.revoked + ]) + .where({ savedViewGroupId }) + return tokens + } + +export const getSavedViewGroupApiTokenFactory = + (deps: { db: Knex }): GetSavedViewGroupApiToken => + async ({ savedViewGroupId }) => { + const token = await tables + .savedGroupApiTokens(deps.db) + .orderBy(ApiTokens.col.createdAt) + .join(ApiTokens.name, ApiTokens.col.id, SavedViewGroupApiTokens.col.tokenId) + .select([ + ...SavedViewGroupApiTokens.cols, + ApiTokens.col.createdAt, + ApiTokens.col.lastUsed, + ApiTokens.col.lifespan, + ApiTokens.col.revoked + ]) + .where({ savedViewGroupId }) + .first() + return token ?? null + } diff --git a/packages/server/modules/viewer/services/tokens.ts b/packages/server/modules/viewer/services/tokens.ts new file mode 100644 index 000000000..b92425032 --- /dev/null +++ b/packages/server/modules/viewer/services/tokens.ts @@ -0,0 +1,81 @@ +import type { + CreateAndStoreUserToken, + GetApiTokenById +} from '@/modules/core/domain/tokens/operations' +import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types' +import { LogicError } from '@/modules/shared/errors' +import type { StoreSavedViewGroupApiToken } from '@/modules/viewer/domain/operations/savedViewGroupApiTokens' +import type { GetSavedViewGroup } from '@/modules/viewer/domain/operations/savedViews' +import type { + SavedViewGroupApiToken, + SavedViewGroupApiTokenRecord +} from '@/modules/viewer/domain/types/savedViewGroupApiTokens' +import { Scopes } from '@speckle/shared' +import { SavedViewGroupNotFoundError } from '@speckle/shared/authz' +import cryptoRandomString from 'crypto-random-string' +import { pick } from 'lodash-es' + +export type CreateAndStoreSavedViewGroupToken = (args: { + savedViewGroupId: string + userId: string + projectId: string + lifespan?: number | bigint +}) => Promise<{ + token: string + tokenMetadata: SavedViewGroupApiToken +}> + +export const createSavedViewGroupTokenFactory = + (deps: { + getSavedViewGroup: GetSavedViewGroup + createToken: CreateAndStoreUserToken + getToken: GetApiTokenById + storeSavedViewGroupApiToken: StoreSavedViewGroupApiToken + }): CreateAndStoreSavedViewGroupToken => + async ({ projectId, savedViewGroupId, userId, lifespan }) => { + const savedViewGroups = await deps.getSavedViewGroup({ + id: savedViewGroupId, + projectId + }) + + if (!savedViewGroups) throw new SavedViewGroupNotFoundError() + if (projectId !== savedViewGroups.projectId) throw new LogicError() + + const { id, token } = await deps.createToken({ + userId, + name: `svgat-${cryptoRandomString({ length: 10 })}`, + scopes: [Scopes.Streams.Read, Scopes.Users.Read], + limitResources: [ + { + id: projectId, + type: TokenResourceIdentifierType.Project + } + ], + lifespan + }) + + const tokenMetadata: SavedViewGroupApiTokenRecord = { + userId, + projectId, + savedViewGroupId, + tokenId: id, + content: token + } + + await deps.storeSavedViewGroupApiToken(tokenMetadata) + + const apiToken = await deps.getToken(id) + + if (!apiToken) { + throw new LogicError('Failed to create api token for saved view group') + } + + return { + token, + tokenMetadata: { + revoked: false, + ...tokenMetadata, + ...pick(apiToken, 'createdAt', 'lastUsed', 'lifespan') + } + } + } diff --git a/packages/server/modules/viewer/tests/unit/tokens.spec.ts b/packages/server/modules/viewer/tests/unit/tokens.spec.ts new file mode 100644 index 000000000..20ff0831e --- /dev/null +++ b/packages/server/modules/viewer/tests/unit/tokens.spec.ts @@ -0,0 +1,86 @@ +import type { ApiTokenRecord } from '@/modules/auth/repositories' +import { LogicError } from '@/modules/shared/errors' +import type { SavedViewGroupApiTokenRecord } from '@/modules/viewer/domain/types/savedViewGroupApiTokens' +import { createSavedViewGroupTokenFactory } from '@/modules/viewer/services/tokens' +import { SavedViewGroupNotFoundError } from '@speckle/shared/authz' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' + +describe('createSavedViewGroupTokenFactory returns a function, that', () => { + const savedViewGroupId = cryptoRandomString({ length: 9 }) + const projectId = cryptoRandomString({ length: 9 }) + const userId = cryptoRandomString({ length: 9 }) + + it('returns a token associated with the given dashboard', async () => { + let token + const createSavedViewGroupToken = createSavedViewGroupTokenFactory({ + getSavedViewGroup: async () => ({ + id: cryptoRandomString({ length: 10 }), + projectId, + name: null, + authorId: null, + resourceIds: [cryptoRandomString({ length: 10 })], + createdAt: new Date(), + updatedAt: new Date() + }), + createToken: async () => ({ + id: cryptoRandomString({ length: 10 }), + token: cryptoRandomString({ length: 20 }) + }), + getToken: async () => ({} as ApiTokenRecord), + storeSavedViewGroupApiToken: async (svgt) => { + token = svgt + return svgt + } + }) + + const result = await createSavedViewGroupToken({ + savedViewGroupId, + projectId, + userId + }) + + expect(result.tokenMetadata.projectId).to.equal(projectId) + expect(token).to.not.be.undefined + }) + + it('throws NotFound if savedViewGroup is not found', async () => { + const createSavedViewGroupToken = createSavedViewGroupTokenFactory({ + getSavedViewGroup: async () => undefined, + createToken: async () => ({ + id: cryptoRandomString({ length: 10 }), + token: cryptoRandomString({ length: 20 }) + }), + getToken: async () => ({} as ApiTokenRecord), + storeSavedViewGroupApiToken: async () => ({} as SavedViewGroupApiTokenRecord) + }) + + const promise = createSavedViewGroupToken({ projectId, savedViewGroupId, userId }) + + expect(promise).to.eventually.throw(SavedViewGroupNotFoundError) + }) + + it('throws LogicError if savedViewGroup belongs to another project', async () => { + const createSavedViewGroupToken = createSavedViewGroupTokenFactory({ + getSavedViewGroup: async () => ({ + id: cryptoRandomString({ length: 10 }), + projectId: cryptoRandomString({ length: 10 }), // wrong project + name: null, + authorId: null, + resourceIds: [cryptoRandomString({ length: 10 })], + createdAt: new Date(), + updatedAt: new Date() + }), + createToken: async () => ({ + id: cryptoRandomString({ length: 10 }), + token: cryptoRandomString({ length: 20 }) + }), + getToken: async () => ({} as ApiTokenRecord), + storeSavedViewGroupApiToken: async () => ({} as SavedViewGroupApiTokenRecord) + }) + + const promise = createSavedViewGroupToken({ projectId, savedViewGroupId, userId }) + + expect(promise).to.eventually.throw(LogicError) + }) +}) diff --git a/packages/shared/src/authz/policies/index.ts b/packages/shared/src/authz/policies/index.ts index efd0c5113..d85d5a5d8 100644 --- a/packages/shared/src/authz/policies/index.ts +++ b/packages/shared/src/authz/policies/index.ts @@ -47,6 +47,7 @@ import { canReadDashboardPolicy } from './dashboard/canRead.js' import { canMoveSavedViewPolicy } from './project/savedViews/canMove.js' import { canEditSavedViewTitlePolicy } from './project/savedViews/canEditTitle.js' import { canEditSavedViewDescriptionPolicy } from './project/savedViews/canEditDescription.js' +import { canCreateSavedViewGroupTokenPolicy } from './project/savedViews/canCreateSavedViewGroupToken.js' export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ automate: { @@ -87,6 +88,7 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ canCreate: canCreateSavedViewPolicy(loaders), canUpdate: canUpdateSavedViewPolicy(loaders), canUpdateGroup: canUpdateSavedViewGroupPolicy(loaders), + canCreateToken: canCreateSavedViewGroupTokenPolicy(loaders), canRead: canReadSavedViewPolicy(loaders), canMove: canMoveSavedViewPolicy(loaders), canEditTitle: canEditSavedViewTitlePolicy(loaders), diff --git a/packages/shared/src/authz/policies/project/savedViews/canCreateSavedViewGroupToken.spec.ts b/packages/shared/src/authz/policies/project/savedViews/canCreateSavedViewGroupToken.spec.ts new file mode 100644 index 000000000..1e25bd5d0 --- /dev/null +++ b/packages/shared/src/authz/policies/project/savedViews/canCreateSavedViewGroupToken.spec.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest' +import { OverridesOf } from '../../../../tests/helpers/types.js' +import { + getEnvFake, + getProjectFake, + getSavedViewGroupFake, + getWorkspaceFake, + getWorkspacePlanFake, + getWorkspaceSsoProviderFake, + getWorkspaceSsoSessionFake +} from '../../../../tests/fakes.js' +import { Roles } from '../../../../core/constants.js' +import { + ProjectNotEnoughPermissionsError, + ServerNoAccessError, + UngroupedSavedViewGroupLockError, + WorkspaceNoAccessError, + WorkspacePlanNoFeatureAccessError, + WorkspacesNotEnabledError +} from '../../../domain/authErrors.js' +import { canCreateSavedViewGroupTokenPolicy } from './canCreateSavedViewGroupToken.js' + +describe('canCreateSavedViewGroupTokenPolicy', () => { + const buildSUT = ( + overrides?: OverridesOf + ) => + canCreateSavedViewGroupTokenPolicy({ + getSavedViewGroup: getSavedViewGroupFake({ + projectId: 'project-id' + }), + getProject: getProjectFake({ + id: 'project-id', + workspaceId: null + }), + getEnv: getEnvFake({ + FF_WORKSPACES_MODULE_ENABLED: true, + FF_SAVED_VIEWS_ENABLED: true + }), + getServerRole: async () => Roles.Server.User, + getProjectRole: async () => Roles.Stream.Owner, + getWorkspaceRole: async () => null, + getWorkspace: async () => null, + getWorkspaceSsoProvider: async () => null, + getWorkspacePlan: async () => null, + getWorkspaceSsoSession: async () => null, + ...overrides + }) + + it('fails in non-workspaced project, even if project owner', async () => { + const policy = buildSUT() + + const result = await policy({ + userId: 'user-id', + projectId: 'project-id', + savedViewGroupId: 'saved-group-id' + }) + + expect(result).toBeAuthErrorResult({ + code: WorkspaceNoAccessError.code + }) + }) + + describe('w/ workspaced project', async () => { + const buildWorkspacedSUT = ( + overrides?: OverridesOf + ) => + buildSUT({ + getProject: getProjectFake({ + id: 'project-id', + workspaceId: 'workspace-id' + }), + getWorkspaceRole: async () => Roles.Workspace.Member, + getWorkspace: getWorkspaceFake({ + id: 'workspace-id' + }), + getWorkspacePlan: getWorkspacePlanFake({ + workspaceId: 'workspace-id', + name: 'pro' + }), + getWorkspaceSsoProvider: getWorkspaceSsoProviderFake({ + providerId: 'sso-provider-id' + }), + getWorkspaceSsoSession: getWorkspaceSsoSessionFake({ + providerId: 'sso-provider-id' + }), + ...overrides + }) + + it('works if user is project owner', async () => { + const sut = buildWorkspacedSUT() + + const result = await sut({ + userId: 'user-id', + projectId: 'project-id', + savedViewGroupId: 'saved-group-id' + }) + + expect(result).toBeOKResult() + }) + + it('fails if workspaces disabled', async () => { + const sut = buildWorkspacedSUT({ + getEnv: getEnvFake({ + FF_WORKSPACES_MODULE_ENABLED: false, + FF_SAVED_VIEWS_ENABLED: true + }) + }) + + const result = await sut({ + userId: 'user-id', + projectId: 'project-id', + savedViewGroupId: 'saved-group-id' + }) + + expect(result).toBeAuthErrorResult({ + code: WorkspacesNotEnabledError.code + }) + }) + + it('fails if saved views disabled', async () => { + const sut = buildWorkspacedSUT({ + getEnv: getEnvFake({ + FF_WORKSPACES_MODULE_ENABLED: true, + FF_SAVED_VIEWS_ENABLED: false + }) + }) + + const result = await sut({ + userId: 'user-id', + projectId: 'project-id', + savedViewGroupId: 'saved-group-id' + }) + + expect(result).toBeAuthErrorResult({ + code: WorkspacePlanNoFeatureAccessError.code + }) + }) + + it('fails if just reviewer', async () => { + const sut = buildWorkspacedSUT({ + getProjectRole: async () => Roles.Stream.Reviewer + }) + + const result = await sut({ + userId: 'user-id', + projectId: 'project-id', + savedViewGroupId: 'saved-group-id' + }) + + expect(result).toBeAuthErrorResult({ + code: ProjectNotEnoughPermissionsError.code + }) + }) + + it('fails if logged out', async () => { + const sut = buildWorkspacedSUT({ + getWorkspaceRole: async () => null, + getServerRole: async () => null, + getProjectRole: async () => null + }) + + const result = await sut({ + userId: 'aaa', + projectId: 'project-id', + savedViewGroupId: 'saved-group-id' + }) + expect(result).toBeAuthErrorResult({ + code: ServerNoAccessError.code + }) + }) + + it('fails if not owner and not the author', async () => { + const sut = buildWorkspacedSUT({ + getSavedViewGroup: getSavedViewGroupFake({ + projectId: 'project-id', + authorId: 'another-user-id' + }), + getProjectRole: async () => Roles.Stream.Contributor + }) + + const result = await sut({ + userId: 'user-id', + projectId: 'project-id', + savedViewGroupId: 'saved-group-id' + }) + + expect(result).toBeAuthErrorResult({ + code: ProjectNotEnoughPermissionsError.code + }) + }) + + it('fails if updating default group', async () => { + const sut = buildWorkspacedSUT({ + getSavedViewGroup: getSavedViewGroupFake({ + projectId: 'project-id', + id: 'default-XXX' + }) + }) + + const result = await sut({ + userId: 'user-id', + projectId: 'project-id', + savedViewGroupId: 'default-XXX' + }) + + expect(result).toBeAuthErrorResult({ + code: UngroupedSavedViewGroupLockError.code + }) + }) + + it('fails if workspace plan is free', async () => { + const sut = buildWorkspacedSUT({ + getWorkspacePlan: getWorkspacePlanFake({ + workspaceId: 'workspace-id', + name: 'free' + }) + }) + + const result = await sut({ + userId: 'user-id', + projectId: 'project-id', + savedViewGroupId: 'default-XXX' + }) + + expect(result).toBeAuthErrorResult({ + code: WorkspacePlanNoFeatureAccessError.code + }) + }) + + it('fails if user is author', async () => { + const sut = buildWorkspacedSUT({ + getSavedViewGroup: getSavedViewGroupFake({ + projectId: 'project-id', + authorId: 'user-id' + }), + getProjectRole: async () => Roles.Stream.Contributor + }) + + const result = await sut({ + userId: 'user-id', + projectId: 'project-id', + savedViewGroupId: 'saved-group-id' + }) + + expect(result).toBeAuthErrorResult({ + code: ProjectNotEnoughPermissionsError.code + }) + }) + }) +}) diff --git a/packages/shared/src/authz/policies/project/savedViews/canCreateSavedViewGroupToken.ts b/packages/shared/src/authz/policies/project/savedViews/canCreateSavedViewGroupToken.ts new file mode 100644 index 000000000..c7ec32801 --- /dev/null +++ b/packages/shared/src/authz/policies/project/savedViews/canCreateSavedViewGroupToken.ts @@ -0,0 +1,117 @@ +import { Roles } from '../../../../core/constants.js' +import { + ProjectNoAccessError, + ProjectNotEnoughPermissionsError, + ProjectNotFoundError, + SavedViewGroupNotFoundError, + ServerNoAccessError, + ServerNoSessionError, + ServerNotEnoughPermissionsError, + UngroupedSavedViewGroupLockError, + WorkspaceNoAccessError, + WorkspaceNotEnoughPermissionsError, + WorkspacePlanNoFeatureAccessError, + WorkspaceReadOnlyError, + WorkspacesNotEnabledError, + WorkspaceSsoSessionNoAccessError +} from '../../../domain/authErrors.js' +import { + MaybeUserContext, + ProjectContext, + SavedViewGroupContext +} from '../../../domain/context.js' +import { Loaders } from '../../../domain/loaders.js' +import { AuthPolicy } from '../../../domain/policies.js' +import { + ensureImplicitProjectMemberWithWriteAccessFragment, + ensureMinimumProjectRoleFragment +} from '../../../fragments/projects.js' +import { ensureCanAccessSavedViewGroupFragment } from '../../../fragments/savedViews.js' +import { err, ok } from 'true-myth/result' + +export const canCreateSavedViewGroupTokenPolicy: AuthPolicy< + | typeof Loaders.getSavedViewGroup + | typeof Loaders.getProject + | typeof Loaders.getEnv + | typeof Loaders.getServerRole + | typeof Loaders.getWorkspaceRole + | typeof Loaders.getWorkspace + | typeof Loaders.getWorkspacePlan + | typeof Loaders.getWorkspaceSsoProvider + | typeof Loaders.getWorkspaceSsoSession + | typeof Loaders.getProjectRole, + MaybeUserContext & ProjectContext & SavedViewGroupContext, + InstanceType< + | typeof SavedViewGroupNotFoundError + | typeof ProjectNotFoundError + | typeof ServerNoAccessError + | typeof ServerNoSessionError + | typeof ProjectNoAccessError + | typeof WorkspaceNoAccessError + | typeof WorkspaceSsoSessionNoAccessError + | typeof ServerNotEnoughPermissionsError + | typeof ProjectNotEnoughPermissionsError + | typeof WorkspaceNotEnoughPermissionsError + | typeof WorkspacesNotEnabledError + | typeof WorkspaceReadOnlyError + | typeof WorkspacePlanNoFeatureAccessError + | typeof UngroupedSavedViewGroupLockError + > +> = + (loaders) => + async ({ userId, projectId, savedViewGroupId }) => { + const canUseSavedViews = await ensureCanAccessSavedViewGroupFragment(loaders)({ + userId, + projectId, + savedViewGroupId, + access: 'write' + }) + + if (canUseSavedViews.isErr) return err(canUseSavedViews.error) + + 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() + } + }