Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WEB-1140 manage user workspace membership services #2460

Merged

Conversation

cdriesler
Copy link
Member

@cdriesler cdriesler commented Jul 1, 2024

Description & motivation

  • We need repo/service functions for basic workspace role operations:
    • Create
    • Update
    • Remove
  • We need to protect against the final admin of a workspace being removed
  • We need to make sure relevant events are emitted by the service functions

Changes:

  • Adds repo functions and relevant integration tests
  • Adds workspace role events and calls them in service functions

Copy link

linear bot commented Jul 1, 2024

const workspacesAclTable = tables.workspacesAcl(db)

// Get current role
const currentRoleQuery = workspacesAclTable
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be in a separate function getCurrentRoleForUserInWorkspace or similar? I assume we'll need to undertake this similar query in other functions?

Should we not return all roles for a user, and then we can check in that list of all roles if they are an admin without making a further db query?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fair. Based on the roles listed I assumed a user would only ever have one role in a workspace.

Am I reading it right that you're suggesting dropping the .where('role', 'workspace:admin') bit and inspecting the list? That makes sense to me as well even if there's only ever one role. Is it strictly worse (performance wise?) to include this extra clause?

}

// Protect against removing last admin in workspace
const isUserLastAdmin = await isUserLastAdminFactory({ db })({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See note above. Can we retrieve all roles for a user in the workspace above, then do an roles.includes('workspace:admin') here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's also important to check, not just that the user is an admin, but that there are no other admins in the workspace. Is that maybe implied in another way from checking a given user's roles?

async ({ userId, workspaceId, role }: WorkspaceAcl): Promise<void> => {
await upsertWorkspaceRole({ userId, workspaceId, role })

// TODO: Should we return the final record from `upsert`, or `get`, instead of emitting args directly?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From upsert, if it returns the values in the row as this should reflect what is stored. If it doesn't return the value, then using the args is ok.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is something I plan to ask about next time we're all around. It looks like, in general, we don't return the final result from upsert functions but this is a case where I'd find it useful.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me, an upsert is a complex conditional operation, cause if the value doesn't exist, you need to insert the full object. So allowing partial upsert needs a lot of guards to get right. I like full upserts more for that reason. If that is true, there must not be any differences between the args object and the stored value.

@cdriesler cdriesler marked this pull request as ready for review July 5, 2024 18:34
@cdriesler
Copy link
Member Author

@iainsproat took another swing, given your comments. I do like this much better lmk what you think

}

// Bail early if user has no role
const currentRole = workspaceRoles.find((role) => role.userId === userId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of running a sql query against the db, inspecting the return value and based on a condition maybe run a second, you can issue a delete command with a returning '*' statement, to get back the deleted rows.

This way you are guaranteed to only run one sql statement.

https://knexjs.org/guide/query-builder.html#returning

const validRoles = Object.values(Roles.Workspace)
if (!validRoles.includes(role)) {
throw new Error(`Unexpected workspace role provided: ${role}`)
}

// Protect against removing last admin in workspace
const workspaceRoles = await getWorkspaceRolesFactory({ db })({ workspaceId })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now this the name of the function is lying to me. This func is more like ensuringAnAdminRemainsAndUpsertsTheRoleFactory

I think this is a lot cleaner as service level guarantee, its an application logic decision, that we do not allow workspaces without admin roles. The repo itself should not implement these guards. If we want that to be a strict dataschema guard, we should use database level guards instead..

// Protect against removing last admin in workspace
const workspaceRoles = await getWorkspaceRolesFactory({ db })({ workspaceId })

if (isUserLastWorkspaceAdmin(workspaceRoles, userId)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There could be cases, where we want to delete the role, even if the user is the last admin ie workspace deletion. ( If its not done via cascading deletes for some reason ). That way, this function becomes even more complex, cause based on an external logic, we might want to allow deletion after all. Its a lot better to have this check in the service implementation.

async ({ userId, workspaceId, role }: WorkspaceAcl): Promise<void> => {
await upsertWorkspaceRole({ userId, workspaceId, role })

// TODO: Should we return the final record from `upsert`, or `get`, instead of emitting args directly?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me, an upsert is a complex conditional operation, cause if the value doesn't exist, you need to insert the full object. So allowing partial upsert needs a lot of guards to get right. I like full upserts more for that reason. If that is true, there must not be any differences between the args object and the stored value.

@cdriesler
Copy link
Member Author

@gjedlicska understood and agreed on all counts. Repo functions are even less opinionated/think-y than I originally thought. Changes are in

const workspaceRoles = await getWorkspaceRoles({ workspaceId })

if (isUserLastWorkspaceAdmin(workspaceRoles, userId)) {
throw new Error('Cannot remove last admin from a workspace.')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a specific error type for this. If we're throwing a generic error, the top level error middleware is the one that will catch it. That in turn will make the server respond with a 500 error code, meaning an internal server error. This needs to be a 400-ish error code.

Bad example in many ways (doing it in the repo function, and we're not returning a proper status code etc) but, here's how we're doing it when changing project roles

throw new StreamAccessUpdateError(

cc @fabis94 this is why i like the results monoid patter. You cannot fuck these up.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can derive a specific error from BaseError and set the status code on it to 400 as a constant. That will make sure the server response code is properly set

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@@ -0,0 +1,13 @@
import { WorkspaceAcl } from '@/modules/workspaces/domain/types'

export const isUserLastWorkspaceAdmin = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing unit tests. I know the functionality is somewhat tested in the services, but in these cases i think it is required to fully test all the edge cases of this functionality.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added, skipped on technically-valid but not possible situations (workspace with no users, workspace with no admins, etc)

happy to add them in if we want to be extra-exhaustive

@gjedlicska gjedlicska self-requested a review July 9, 2024 16:26
@gjedlicska gjedlicska merged commit e703bb7 into main Jul 9, 2024
21 of 23 checks passed
@gjedlicska gjedlicska deleted the chuck/web-1140-manage-user-workspace-membership-services branch July 9, 2024 16:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants