feat(savedViews): share presentations (#5523)

* share presentations basics

* fix: issues and resolvers

* missing gqlgen

* fix

* fix: gql types

* feat: minor changes

* fix: fk and policies

* feat: add shareLink

* feat: remove useless error

* fix: minor

* fix: tests
This commit is contained in:
Daniel Gak Anagrov
2025-09-29 14:30:05 +02:00
committed by GitHub
parent 0e56be7b8f
commit d41f59be11
16 changed files with 965 additions and 1 deletions
@@ -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<Project>;
savedViewGroupId: Scalars['String']['output'];
tokenId: Scalars['String']['output'];
user?: Maybe<LimitedUser>;
};
export type SavedViewGroupTokenReturn = {
__typename?: 'SavedViewGroupTokenReturn';
token: Scalars['String']['output'];
tokenMetadata: SavedViewGroupToken;
};
export type SavedViewGroupViewsInput = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -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,
@@ -18,6 +18,7 @@ extend type SavedView {
}
type SavedViewGroupPermissionChecks {
canCreateToken: PermissionCheckResult!
canUpdate: PermissionCheckResult!
}
@@ -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!
}
@@ -3722,6 +3722,7 @@ export type SavedViewGroup = {
projectId: Scalars['ID']['output'];
/** Resources that were used to find this group */
resourceIds: Array<Scalars['String']['output']>;
shareLink?: Maybe<SavedViewGroupShareLink>;
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<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -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<SavedViewGroupGraphQLReturn>;
SavedViewGroupCollection: ResolverTypeWrapper<Omit<SavedViewGroupCollection, 'items'> & { items: Array<ResolversTypes['SavedViewGroup']> }>;
SavedViewGroupPermissionChecks: ResolverTypeWrapper<SavedViewGroupPermissionChecksGraphQLReturn>;
SavedViewGroupShareInput: SavedViewGroupShareInput;
SavedViewGroupShareLink: ResolverTypeWrapper<SavedViewGroupShareLink>;
SavedViewGroupShareUpdateInput: SavedViewGroupShareUpdateInput;
SavedViewGroupViewsInput: SavedViewGroupViewsInput;
SavedViewGroupsInput: SavedViewGroupsInput;
SavedViewMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
@@ -6788,6 +6837,9 @@ export type ResolversParentTypes = {
SavedViewGroup: SavedViewGroupGraphQLReturn;
SavedViewGroupCollection: Omit<SavedViewGroupCollection, 'items'> & { items: Array<ResolversParentTypes['SavedViewGroup']> };
SavedViewGroupPermissionChecks: SavedViewGroupPermissionChecksGraphQLReturn;
SavedViewGroupShareInput: SavedViewGroupShareInput;
SavedViewGroupShareLink: SavedViewGroupShareLink;
SavedViewGroupShareUpdateInput: SavedViewGroupShareUpdateInput;
SavedViewGroupViewsInput: SavedViewGroupViewsInput;
SavedViewGroupsInput: SavedViewGroupsInput;
SavedViewMutations: MutationsObjectGraphQLReturn;
@@ -8246,6 +8298,7 @@ export type SavedViewGroupResolvers<ContextType = GraphQLContext, ParentType ext
permissions?: Resolver<ResolversTypes['SavedViewGroupPermissionChecks'], ParentType, ContextType>;
projectId?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
resourceIds?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
shareLink?: Resolver<Maybe<ResolversTypes['SavedViewGroupShareLink']>, ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
views?: Resolver<ResolversTypes['SavedViewCollection'], ParentType, ContextType, RequireFields<SavedViewGroupViewsArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@@ -8259,15 +8312,29 @@ export type SavedViewGroupCollectionResolvers<ContextType = GraphQLContext, Pare
};
export type SavedViewGroupPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['SavedViewGroupPermissionChecks'] = ResolversParentTypes['SavedViewGroupPermissionChecks']> = {
canCreateToken?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canUpdate?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SavedViewGroupShareLinkResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['SavedViewGroupShareLink'] = ResolversParentTypes['SavedViewGroupShareLink']> = {
content?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
revoked?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
validUntil?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SavedViewMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['SavedViewMutations'] = ResolversParentTypes['SavedViewMutations']> = {
createGroup?: Resolver<ResolversTypes['SavedViewGroup'], ParentType, ContextType, RequireFields<SavedViewMutationsCreateGroupArgs, 'input'>>;
createView?: Resolver<ResolversTypes['SavedView'], ParentType, ContextType, RequireFields<SavedViewMutationsCreateViewArgs, 'input'>>;
deleteGroup?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<SavedViewMutationsDeleteGroupArgs, 'input'>>;
deleteShare?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<SavedViewMutationsDeleteShareArgs, 'input'>>;
deleteView?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<SavedViewMutationsDeleteViewArgs, 'input'>>;
disableShare?: Resolver<ResolversTypes['SavedViewGroupShareLink'], ParentType, ContextType, RequireFields<SavedViewMutationsDisableShareArgs, 'input'>>;
enableShare?: Resolver<ResolversTypes['SavedViewGroupShareLink'], ParentType, ContextType, RequireFields<SavedViewMutationsEnableShareArgs, 'input'>>;
share?: Resolver<ResolversTypes['SavedViewGroupShareLink'], ParentType, ContextType, RequireFields<SavedViewMutationsShareArgs, 'input'>>;
updateGroup?: Resolver<ResolversTypes['SavedViewGroup'], ParentType, ContextType, RequireFields<SavedViewMutationsUpdateGroupArgs, 'input'>>;
updateView?: Resolver<ResolversTypes['SavedView'], ParentType, ContextType, RequireFields<SavedViewMutationsUpdateViewArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@@ -9192,6 +9259,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
SavedViewGroup?: SavedViewGroupResolvers<ContextType>;
SavedViewGroupCollection?: SavedViewGroupCollectionResolvers<ContextType>;
SavedViewGroupPermissionChecks?: SavedViewGroupPermissionChecksResolvers<ContextType>;
SavedViewGroupShareLink?: SavedViewGroupShareLinkResolvers<ContextType>;
SavedViewMutations?: SavedViewMutationsResolvers<ContextType>;
SavedViewPermissionChecks?: SavedViewPermissionChecksResolvers<ContextType>;
Scope?: ScopeResolvers<ContextType>;
@@ -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:
@@ -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<SavedViewGroupApiTokenRecord, T>
>(
token: T
) => Promise<SavedViewGroupApiTokenRecord>
export type DeleteSavedViewGroupApiToken = (args: {
tokenId: string
}) => Promise<SavedViewGroupApiTokenRecord | null>
export type GetSavedViewGroupApiTokens = (args: {
savedViewGroupId: string
}) => Promise<SavedViewGroupApiToken[]>
export type GetSavedViewGroupApiToken = (args: {
savedViewGroupId: string
}) => Promise<SavedViewGroupApiToken | null>
@@ -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
}
@@ -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({
@@ -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
@@ -0,0 +1,31 @@
import type { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
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<void> {
await knex.schema.dropTableIfExists('saved_view_group_api_tokens')
}
@@ -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<SavedViewGroupApiTokenRecord>(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<SavedViewGroupApiToken[]>([
...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<SavedViewGroupApiToken[]>([
...SavedViewGroupApiTokens.cols,
ApiTokens.col.createdAt,
ApiTokens.col.lastUsed,
ApiTokens.col.lifespan,
ApiTokens.col.revoked
])
.where({ savedViewGroupId })
.first()
return token ?? null
}
@@ -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')
}
}
}
@@ -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)
})
})
@@ -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),
@@ -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<typeof canCreateSavedViewGroupTokenPolicy>
) =>
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<typeof canCreateSavedViewGroupTokenPolicy>
) =>
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
})
})
})
})
@@ -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()
}
}