Skip to content

Commit

Permalink
WEB-1140 manage user workspace membership services (#2460)
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

* fix(workspaces): maybe tests work like this

* fix(workspaces): dry

* fix(workspaces): initialize tests better

* fix(workspaces): so true

* fix(workspaces): right

* fix(workspaces): self nit

* fix(workspaces): better repository structure

* fix(workspaces): repair tests, use `example.org`

* fix(workspaces): add tests for new repo functions, repair other tests

* fix(workspaces): better distinction between service-level guarantees and repo-level guarantees

* fix(workspaces): review comments and stencil tests

* fix(workspaces): add tests

* fix(workspaces): tests work

---------

Co-authored-by: Gergő Jedlicska <[email protected]>
  • Loading branch information
cdriesler and gjedlicska authored Jul 9, 2024
1 parent a72ef61 commit e703bb7
Show file tree
Hide file tree
Showing 10 changed files with 590 additions and 15 deletions.
40 changes: 36 additions & 4 deletions packages/server/modules/workspaces/domain/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,49 @@ export type GetWorkspace = (args: GetWorkspaceArgs) => Promise<Workspace | null>

/** WorkspaceRole */

export type UpsertWorkspaceRole = (args: WorkspaceAcl) => Promise<void>
type DeleteWorkspaceRoleArgs = {
workspaceId: string
userId: string
}

type GetWorkspaceRoleArgs = {
export type DeleteWorkspaceRole = (
args: DeleteWorkspaceRoleArgs
) => Promise<WorkspaceAcl | null>

type GetWorkspaceRolesArgs = {
workspaceId: string
}

/** Get all roles in a given workspaces. */
export type GetWorkspaceRoles = (args: GetWorkspaceRolesArgs) => Promise<WorkspaceAcl[]>

type GetWorkspaceRoleForUserArgs = {
userId: string
workspaceId: string
}

export type GetWorkspaceRole = (
args: GetWorkspaceRoleArgs
/** Get role for given user in a specific workspace. */
export type GetWorkspaceRoleForUser = (
args: GetWorkspaceRoleForUserArgs
) => Promise<WorkspaceAcl | null>

type GetWorkspaceRolesForUserArgs = {
userId: string
}

type GetWorkspaceRolesForUserOptions = {
/** If provided, limit results to roles in given workspaces. */
workspaceIdFilter?: string[]
}

/** Get roles for given user across several (or all) workspaces. */
export type GetWorkspaceRolesForUser = (
args: GetWorkspaceRolesForUserArgs,
options?: GetWorkspaceRolesForUserOptions
) => Promise<WorkspaceAcl[]>

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

/** Blob */

export type StoreBlob = (args: string) => Promise<string>
Expand Down
7 changes: 7 additions & 0 deletions packages/server/modules/workspaces/errors/workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { BaseError } from '@/modules/shared/errors/base'

export class WorkspaceAdminRequiredError extends BaseError {
static defaultMessage = 'Cannot remove last admin from a workspace'
static code = 'WORKSPACE_ADMIN_REQUIRED_ERROR'
static statusCode = 400
}
57 changes: 49 additions & 8 deletions packages/server/modules/workspaces/repositories/workspaces.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Workspace, WorkspaceAcl } from '@/modules/workspaces/domain/types'
import {
DeleteWorkspaceRole,
GetWorkspace,
GetWorkspaceRole,
GetWorkspaceRoleForUser,
GetWorkspaceRoles,
GetWorkspaceRolesForUser,
UpsertWorkspace,
UpsertWorkspaceRole
} from '@/modules/workspaces/domain/operations'
Expand Down Expand Up @@ -35,21 +38,59 @@ export const upsertWorkspaceFactory =
.merge(['description', 'logoUrl', 'name', 'updatedAt'])
}

export const getWorkspaceRoleFactory =
({ db }: { db: Knex }): GetWorkspaceRole =>
export const getWorkspaceRolesFactory =
({ db }: { db: Knex }): GetWorkspaceRoles =>
async ({ workspaceId }) => {
return await tables.workspacesAcl(db).select('*').where({ workspaceId })
}

export const getWorkspaceRoleForUserFactory =
({ db }: { db: Knex }): GetWorkspaceRoleForUser =>
async ({ userId, workspaceId }) => {
return (
(await tables
.workspacesAcl(db)
.select('*')
.where({ userId, workspaceId })
.first()) ?? null
)
}

export const getWorkspaceRolesForUserFactory =
({ db }: { db: Knex }): GetWorkspaceRolesForUser =>
async ({ userId }, options) => {
const workspaceIdFilter = options?.workspaceIdFilter ?? []

const query = tables.workspacesAcl(db).select('*').where({ userId })

if (workspaceIdFilter.length > 0) {
query.whereIn('workspaceId', workspaceIdFilter)
}

return await query
}

export const deleteWorkspaceRoleFactory =
({ db }: { db: Knex }): DeleteWorkspaceRole =>
async ({ userId, workspaceId }) => {
const acl = await tables
const deletedRoles = await tables
.workspacesAcl(db)
.select('*')
.where({ userId, workspaceId })
.first()
.where({ workspaceId, userId })
.delete('*')

if (deletedRoles.length === 0) {
return null
}

return acl || null
// Given `workspaceId` and `userId` define a primary key for `workspace_acl` table,
// query returns either 0 or 1 row in all cases
return deletedRoles[0]
}

export const upsertWorkspaceRoleFactory =
({ db }: { db: Knex }): UpsertWorkspaceRole =>
async ({ userId, workspaceId, role }) => {
// 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}`)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
DeleteWorkspaceRole,
EmitWorkspaceEvent,
GetWorkspaceRoleForUser,
GetWorkspaceRoles,
UpsertWorkspaceRole
} from '@/modules/workspaces/domain/operations'
import { WorkspaceAcl } from '@/modules/workspaces/domain/types'
import { WorkspaceAdminRequiredError } from '@/modules/workspaces/errors/workspace'
import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/isUserLastWorkspaceAdmin'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'

type WorkspaceRoleDeleteArgs = {
userId: string
workspaceId: string
}

export const deleteWorkspaceRoleFactory =
({
getWorkspaceRoles,
deleteWorkspaceRole,
emitWorkspaceEvent
}: {
getWorkspaceRoles: GetWorkspaceRoles
deleteWorkspaceRole: DeleteWorkspaceRole
emitWorkspaceEvent: EmitWorkspaceEvent
}) =>
async ({
userId,
workspaceId
}: WorkspaceRoleDeleteArgs): Promise<WorkspaceAcl | null> => {
const workspaceRoles = await getWorkspaceRoles({ workspaceId })

if (isUserLastWorkspaceAdmin(workspaceRoles, userId)) {
throw new WorkspaceAdminRequiredError()
}

const deletedRole = await deleteWorkspaceRole({ userId, workspaceId })

if (!deletedRole) {
return null
}

emitWorkspaceEvent({ event: WorkspaceEvents.RoleDeleted, payload: deletedRole })

return deletedRole
}

type WorkspaceRoleGetArgs = {
userId: string
workspaceId: string
}

export const getWorkspaceRoleFactory =
({ getWorkspaceRoleForUser }: { getWorkspaceRoleForUser: GetWorkspaceRoleForUser }) =>
async ({
userId,
workspaceId
}: WorkspaceRoleGetArgs): Promise<WorkspaceAcl | null> => {
return await getWorkspaceRoleForUser({ userId, workspaceId })
}

export const setWorkspaceRoleFactory =
({
getWorkspaceRoles,
upsertWorkspaceRole,
emitWorkspaceEvent
}: {
getWorkspaceRoles: GetWorkspaceRoles
upsertWorkspaceRole: UpsertWorkspaceRole
emitWorkspaceEvent: EmitWorkspaceEvent
}) =>
async ({ userId, workspaceId, role }: WorkspaceAcl): Promise<void> => {
const workspaceRoles = await getWorkspaceRoles({ workspaceId })

if (
isUserLastWorkspaceAdmin(workspaceRoles, userId) &&
role !== 'workspace:admin'
) {
throw new WorkspaceAdminRequiredError()
}

await upsertWorkspaceRole({ userId, workspaceId, role })

await emitWorkspaceEvent({
event: WorkspaceEvents.RoleUpdated,
payload: { userId, workspaceId, role }
})
}
Loading

0 comments on commit e703bb7

Please sign in to comment.