Skip to content

Commit

Permalink
feat(workspaces): assign project roles for workspace projects (#2499)
Browse files Browse the repository at this point in the history
* feat(workspaces): drop createdByUserId from the dataschema

* feat(workspaces): repositories WIP

* merge

* protect against removing last admin in workspace

* quick impl and stub tests

* add tests

* services

* unit tests for role services

* feat(workspaces): authorize project creation if workspace specified

* feat(workspaces): emit project created event

* feat(workspaces): assign roles on project create in workspace

* feat(workspaces): update project roles when user added to workspace

* fix(workspaces): perform automatic project role update in service function

* fix(workspaces): also delete roles

* fix(workspaces): broke tests again oops

* fix(workspaces): update `onProjectCreated` listener to use new repo method

* fix(workspaces): use service function in event listener

* fix(workspaces): get workspace projects via existing stream repo functions

* fix(workspaces): roles mapping in domain, use enum

* fix(workspaces): repair type reference in tests

* fix(workspaces): consolidate files, use different existing stream-getter

* fix(workspaces): more specific error

* fix(workspaces): yield per page

* fix(workspaces): some test dry

* fix(workspaces): superdry

* fix(workspaces): classic

---------

Co-authored-by: Gergő Jedlicska <[email protected]>
  • Loading branch information
cdriesler and gjedlicska authored Jul 19, 2024
1 parent 27179ad commit 66eb539
Show file tree
Hide file tree
Showing 21 changed files with 866 additions and 422 deletions.
1 change: 1 addition & 0 deletions packages/server/modules/core/helpers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export type StreamRecord = {
updatedAt: Date
allowPublicComments: boolean
isDiscoverable: boolean
workspaceId: Nullable<string>
}

export type StreamAclRecord = {
Expand Down
3 changes: 2 additions & 1 deletion packages/server/modules/core/services/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ export const adminProjectList = async (
...args,
searchQuery: args.query,
cursor: parsedCursor,
streamIdWhitelist: args.streamIdWhitelist
streamIdWhitelist: args.streamIdWhitelist,
workspaceIdWhitelist: null
})
const cursor = cursorDate ? convertDateToCursor(cursorDate) : null
return {
Expand Down
8 changes: 7 additions & 1 deletion packages/server/modules/core/services/streams.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ module.exports = {
orderBy,
visibility,
searchQuery,
streamIdWhitelist
streamIdWhitelist,
workspaceIdWhitelist
}) {
const query = knex.select().from('streams')

Expand Down Expand Up @@ -116,6 +117,11 @@ module.exports = {
countQuery.whereIn('id', streamIdWhitelist)
}

if (workspaceIdWhitelist?.length) {
query.whereIn('workspaceId', workspaceIdWhitelist)
countQuery.whereIn('workspaceId', workspaceIdWhitelist)
}

const [res] = await countQuery.count()
const count = parseInt(res.count)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ describe('Activity digest notifications @notifications', () => {
createdAt: new Date(),
updatedAt: new Date(),
allowPublicComments: true,
isDiscoverable: true
isDiscoverable: true,
workspaceId: null
},
activity: activities ?? [createActivity()]
})
Expand Down
25 changes: 24 additions & 1 deletion packages/server/modules/workspaces/domain/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
WorkspaceEvents,
WorkspaceEventsPayloads
} from '@/modules/workspacesCore/domain/events'
import { StreamRecord } from '@/modules/core/helpers/types'
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'

/** Workspace */
Expand All @@ -18,7 +19,7 @@ type GetWorkspaceArgs = {

export type GetWorkspace = (args: GetWorkspaceArgs) => Promise<Workspace | null>

/** WorkspaceRole */
/** Workspace Roles */

type DeleteWorkspaceRoleArgs = {
workspaceId: string
Expand Down Expand Up @@ -63,6 +64,28 @@ export type GetWorkspaceRolesForUser = (

export type UpsertWorkspaceRole = (args: WorkspaceAcl) => Promise<void>

/** Workspace Projects */

type GetAllWorkspaceProjectsForUserArgs = {
userId: string
workspaceId: string
}

export type GetAllWorkspaceProjectsForUser = (
args: GetAllWorkspaceProjectsForUserArgs
) => Promise<StreamRecord[]>

/** Workspace Project Roles */

type GrantWorkspaceProjectRolesArgs = {
projectId: string
workspaceId: string
}

export type GrantWorkspaceProjectRoles = (
args: GrantWorkspaceProjectRolesArgs
) => Promise<void>

/** Blob */

export type StoreBlob = (args: string) => Promise<string>
Expand Down
16 changes: 16 additions & 0 deletions packages/server/modules/workspaces/domain/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Roles, StreamRoles, WorkspaceRoles } from '@speckle/shared'

/**
* Given a user's workspace role, return the role they should have for workspace projects.
*/
export const mapWorkspaceRoleToProjectRole = (
workspaceRole: WorkspaceRoles
): StreamRoles => {
switch (workspaceRole) {
case Roles.Workspace.Guest:
case Roles.Workspace.Member:
return Roles.Stream.Reviewer
case Roles.Workspace.Admin:
return Roles.Stream.Owner
}
}
10 changes: 10 additions & 0 deletions packages/server/modules/workspaces/errors/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ export class WorkspaceAdminRequiredError extends BaseError {
static statusCode = 400
}

export class WorkspaceInvalidRoleError extends BaseError {
static defaultMessage = 'Invalid workspace role provided'
static code = 'WORKSPACE_INVALID_ROLE_ERROR'
}

export class WorkspaceQueryError extends BaseError {
static defaultMessage = 'Unexpected error during query operation'
static code = 'WORKSPACE_QUERY_ERROR'
}

export class WorkspacesNotYetImplementedError extends BaseError {
static defaultMessage = 'Not yet implemented'
static code = 'WORKSPACES_NOT_YET_IMPLEMENTED_ERROR'
Expand Down
52 changes: 52 additions & 0 deletions packages/server/modules/workspaces/events/eventListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
ProjectsEmitter,
ProjectEvents,
ProjectEventsPayloads
} from '@/modules/core/events/projectsEmitter'
import { getWorkspaceRolesFactory } from '@/modules/workspaces/repositories/workspaces'
import { grantStreamPermissions as repoGrantStreamPermissions } from '@/modules/core/repositories/streams'
import { Knex } from 'knex'
import { GetWorkspaceRoles } from '@/modules/workspaces/domain/operations'
import { mapWorkspaceRoleToProjectRole } from '@/modules/workspaces/domain/roles'

export const onProjectCreatedFactory =
({
getWorkspaceRoles,
grantStreamPermissions
}: {
getWorkspaceRoles: GetWorkspaceRoles
grantStreamPermissions: typeof repoGrantStreamPermissions
}) =>
async (payload: ProjectEventsPayloads[typeof ProjectEvents.Created]) => {
const { id: projectId, workspaceId } = payload.project

if (!workspaceId) {
return
}

const workspaceMembers = await getWorkspaceRoles({ workspaceId })

await Promise.all(
workspaceMembers.map(({ userId, role: workspaceRole }) =>
grantStreamPermissions({
streamId: projectId,
userId,
role: mapWorkspaceRoleToProjectRole(workspaceRole)
})
)
)
}

export const initializeEventListenersFactory =
({ db }: { db: Knex }) =>
() => {
const onProjectCreated = onProjectCreatedFactory({
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
// TODO: Instantiate via factory function
grantStreamPermissions: repoGrantStreamPermissions
})

const quitCbs = [ProjectsEmitter.listen(ProjectEvents.Created, onProjectCreated)]

return () => quitCbs.forEach((quit) => quit())
}
6 changes: 5 additions & 1 deletion packages/server/modules/workspaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { workspaceRoles } from '@/modules/workspaces/roles'
import { workspaceScopes } from '@/modules/workspaces/scopes'
import { registerOrUpdateRole } from '@/modules/shared/repositories/roles'
import { initializeEventListenersFactory } from '@/modules/workspaces/events/eventListener'

const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()

Expand All @@ -20,9 +21,12 @@ const initRoles = () => {
}

const workspacesModule: SpeckleModule = {
async init() {
async init(_, isInitial) {
if (!FF_WORKSPACES_MODULE_ENABLED) return
moduleLogger.info('⚒️ Init workspaces module')
if (isInitial) {
initializeEventListenersFactory({ db })()
}
await Promise.all([initScopes(), initRoles()])
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import {
} from '@/modules/workspaces/domain/operations'
import { Knex } from 'knex'
import { Roles } from '@speckle/shared'
import { StreamRecord } from '@/modules/core/helpers/types'
import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace'

const tables = {
streams: (db: Knex) => db<StreamRecord>('streams'),
workspaces: (db: Knex) => db<Workspace>('workspaces'),
workspacesAcl: (db: Knex) => db<WorkspaceAcl>('workspace_acl')
}
Expand Down Expand Up @@ -93,7 +96,7 @@ export const upsertWorkspaceRoleFactory =
// Verify requested role is valid workspace role
const validRoles = Object.values(Roles.Workspace)
if (!validRoles.includes(role)) {
throw new Error(`Unexpected workspace role provided: ${role}`)
throw new WorkspaceInvalidRoleError()
}

await tables
Expand Down
Loading

0 comments on commit 66eb539

Please sign in to comment.