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

Feat disable duplicate user or group permission #242

Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ describe('WorkspaceCreator', () => {
color: '#000000',
description: 'test workspace description',
defaultVISTheme: 'categorical',
})
}),
expect.any(Array)
);
await waitFor(() => {
expect(notificationToastsAddSuccess).toHaveBeenCalled();
Expand All @@ -190,7 +191,8 @@ describe('WorkspaceCreator', () => {
expect.objectContaining({
name: 'test workspace name',
features: expect.arrayContaining(['app1', 'app2', 'app3']),
})
}),
expect.any(Array)
);
await waitFor(() => {
expect(notificationToastsAddSuccess).toHaveBeenCalled();
Expand All @@ -216,10 +218,8 @@ describe('WorkspaceCreator', () => {
expect(workspaceClientCreate).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test workspace name',
permissions: expect.arrayContaining([
expect.objectContaining({ type: 'user', userId: 'test user id' }),
]),
})
}),
expect.arrayContaining([expect.objectContaining({ type: 'user', userId: 'test user id' })])
);
await waitFor(() => {
expect(notificationToastsAddSuccess).toHaveBeenCalled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export const WorkspaceCreator = () => {
async (data: WorkspaceFormSubmitData) => {
let result;
try {
result = await workspaceClient.create(data);
const { permissions, ...attributes } = data;
result = await workspaceClient.create(attributes, permissions);
} catch (error) {
notifications?.toasts.addDanger({
title: i18n.translate('workspace.create.failed', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => {
return numberOfErrors;
};

const isUserOrGroupPermissionSettingDuplicated = (
permissionSettings: Array<Partial<WorkspacePermissionSetting>>,
permissionSettingToCheck: WorkspacePermissionSetting
) =>
permissionSettings.some(
(permissionSetting) =>
(permissionSettingToCheck.type === WorkspacePermissionItemType.User &&
permissionSetting.type === WorkspacePermissionItemType.User &&
permissionSettingToCheck.userId === permissionSetting.userId) ||
(permissionSettingToCheck.type === WorkspacePermissionItemType.Group &&
permissionSetting.type === WorkspacePermissionItemType.Group &&
permissionSettingToCheck.group === permissionSetting.group)
);

const workspaceHtmlIdGenerator = htmlIdGenerator();

const defaultVISThemeOptions = [{ value: 'categorical', text: 'Categorical' }];
Expand Down Expand Up @@ -377,6 +391,14 @@ export const WorkspaceForm = ({
for (let i = 0; i < formData.permissions.length; i++) {
const permission = formData.permissions[i];
if (isValidWorkspacePermissionSetting(permission)) {
if (
isUserOrGroupPermissionSettingDuplicated(formData.permissions.slice(0, i), permission)
) {
permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.group', {
defaultMessage: 'Duplicate permission setting',
});
continue;
}
continue;
}
if (!permission.type) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ export const WorkspaceUpdater = () => {
return;
}
try {
result = await workspaceClient.update(currentWorkspace?.id, data);
const { permissions, ...attributes } = data;
result = await workspaceClient.update(currentWorkspace?.id, attributes, permissions);
} catch (error) {
notifications?.toasts.addDanger({
title: i18n.translate('workspace.update.failed', {
Expand Down
14 changes: 6 additions & 8 deletions src/plugins/workspace/public/workspace_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,16 +176,16 @@ export class WorkspaceClient {
* @returns
*/
public async create(
attributes: Omit<WorkspaceAttribute, 'id'> & {
permissions: WorkspaceRoutePermissionItem[];
}
attributes: Omit<WorkspaceAttribute, 'id'>,
permissions: WorkspaceRoutePermissionItem[]
Copy link
Owner

Choose a reason for hiding this comment

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

Maybe not in the scope, but should permissions be optional? Because user may not have permission control turned on.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sure, the permissions could be optional.

): Promise<IResponse<WorkspaceAttribute>> {
const path = this.getPath();

const result = await this.safeFetch<WorkspaceAttribute>(path, {
method: 'POST',
body: JSON.stringify({
attributes,
permissions,
}),
});

Expand Down Expand Up @@ -264,15 +264,13 @@ export class WorkspaceClient {
*/
public async update(
id: string,
attributes: Partial<
WorkspaceAttribute & {
permissions: WorkspaceRoutePermissionItem[];
}
>
attributes: Partial<WorkspaceAttribute>,
permissions: WorkspaceRoutePermissionItem[]
): Promise<IResponse<boolean>> {
const path = this.getPath(id);
const body = {
attributes,
permissions,
};

const result = await this.safeFetch(path, {
Expand Down
37 changes: 16 additions & 21 deletions src/plugins/workspace/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ const workspacePermissionMode = schema.oneOf([
schema.literal(WorkspacePermissionMode.Write),
schema.literal(WorkspacePermissionMode.LibraryRead),
schema.literal(WorkspacePermissionMode.LibraryWrite),
schema.literal(WorkspacePermissionMode.Read),
schema.literal(WorkspacePermissionMode.Write),
]);

const workspacePermission = schema.oneOf([
Expand All @@ -47,9 +45,6 @@ const workspaceAttributesSchema = schema.object({
icon: schema.maybe(schema.string()),
reserved: schema.maybe(schema.boolean()),
defaultVISTheme: schema.maybe(schema.string()),
permissions: schema.maybe(
schema.oneOf([workspacePermission, schema.arrayOf(workspacePermission)])
),
});

const convertToACL = (
Expand Down Expand Up @@ -180,31 +175,29 @@ export function registerRoutes({
validate: {
body: schema.object({
attributes: workspaceAttributesSchema,
permissions: schema.maybe(
schema.oneOf([workspacePermission, schema.arrayOf(workspacePermission)])
),
}),
},
},
router.handleLegacyErrors(async (context, req, res) => {
const { attributes } = req.body;
const { attributes, permissions: permissionsInRequest } = req.body;
const rawRequest = ensureRawRequest(req);
const authInfo = rawRequest?.auth?.credentials?.authInfo as { user_name?: string } | null;
const { permissions: permissionsInAttributes, ...others } = attributes;
let permissions: WorkspaceRoutePermissionItem[] = [];
if (permissionsInAttributes) {
permissions = Array.isArray(permissionsInAttributes)
? permissionsInAttributes
: [permissionsInAttributes];
if (permissionsInRequest) {
permissions = Array.isArray(permissionsInRequest)
? permissionsInRequest
: [permissionsInRequest];
}

// Assign workspace owner to current user
if (!!authInfo?.user_name) {
permissions.push({
type: 'user',
userId: authInfo.user_name,
modes: [WorkspacePermissionMode.LibraryWrite],
});
permissions.push({
type: 'user',
userId: authInfo.user_name,
modes: [WorkspacePermissionMode.Write],
modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write],
});
}

Expand All @@ -215,7 +208,7 @@ export function registerRoutes({
logger,
},
{
...others,
...attributes,
...(permissions.length ? { permissions: convertToACL(permissions) } : {}),
}
);
Expand All @@ -231,13 +224,15 @@ export function registerRoutes({
}),
body: schema.object({
attributes: workspaceAttributesSchema,
permissions: schema.maybe(
schema.oneOf([workspacePermission, schema.arrayOf(workspacePermission)])
),
}),
},
},
router.handleLegacyErrors(async (context, req, res) => {
const { id } = req.params;
const { attributes } = req.body;
const { permissions, ...others } = attributes;
const { attributes, permissions } = req.body;
let finalPermissions: WorkspaceRoutePermissionItem[] = [];
if (permissions) {
finalPermissions = Array.isArray(permissions) ? permissions : [permissions];
Expand All @@ -251,7 +246,7 @@ export function registerRoutes({
},
id,
{
...others,
...attributes,
...(finalPermissions.length ? { permissions: convertToACL(finalPermissions) } : {}),
}
);
Expand Down
50 changes: 50 additions & 0 deletions src/plugins/workspace/server/workspace_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,43 @@ import {
WORKSPACE_UPDATE_APP_ID,
} from '../common/constants';

const validatePermissionModesCombinations = [
[WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], // Read
[WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read], // Write
[WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], // Admin
];

const isValidatePermissionModesCombination = (permissionModes: string[]) =>
validatePermissionModesCombinations.some(
(combination) =>
combination.length === permissionModes.length &&
combination.every((mode) => permissionModes.includes(mode))
);
const isValidatePermissions = (permissions: Permissions) => {
Copy link
Owner

Choose a reason for hiding this comment

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

If we move convertToACL to workspace_client, and passing the raw permissions(type is WorkspaceRoutePermissionItem[]) to create/update, perhaps we don't need to convert the data here? It would be much easier to validate a flatten array of WorkspaceRoutePermissionItem[].

Not only make it easier to validate the permission mode combinations, but also make it easier to validate the user/group duplications here as well.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That's a good idea. The WorkspaceRoutePermissionItem[] in route level was only used in the front end page. I kept the ACL parameter in case of passing ACL to workspace client directly. From current implementation, the create or update method only be used in workspace routes. I think we can change to parameter type to WorkspaceRoutePermissionItem[].

Copy link
Owner

Choose a reason for hiding this comment

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

we also can rename WorkspaceRoutePermissionItem to not include word Route so that it's more generic,

const userOrGroupKey2PermissionModes = permissions
? Object.keys(permissions).reduce<{
[key: string]: string[];
}>((previousValue, permissionMode) => {
permissions[permissionMode].users?.forEach((user) => {
const key = `user-${user}`;
previousValue[key] = [...(previousValue[key] || []), permissionMode];
});
permissions[permissionMode].groups?.forEach((user) => {
const key = `group-${user}`;
previousValue[key] = [...(previousValue[key] || []), permissionMode];
});
return previousValue;
}, {})
: {};

for (const key in userOrGroupKey2PermissionModes) {
if (!isValidatePermissionModesCombination(userOrGroupKey2PermissionModes[key])) {
return false;
}
}
return true;
};

const WORKSPACE_ID_SIZE = 6;

const DUPLICATE_WORKSPACE_NAME_ERROR = i18n.translate('workspace.duplicate.name.error', {
Expand All @@ -49,6 +86,10 @@ const RESERVED_WORKSPACE_NAME_ERROR = i18n.translate('workspace.reserved.name.er
defaultMessage: 'reserved workspace name cannot be changed',
});

const INVALID_PERMISSION_MODES_COMBINATION = i18n.translate('workspace.invalid.permission.error', {
defaultMessage: 'Invalid workspace permission mode combination',
});

export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl {
private setupDep: CoreSetup;
private logger: Logger;
Expand Down Expand Up @@ -207,6 +248,11 @@ export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl {
if (existingWorkspaceRes && existingWorkspaceRes.total > 0) {
throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR);
}

if (permissions && !isValidatePermissions(permissions)) {
throw new Error(INVALID_PERMISSION_MODES_COMBINATION);
}

const result = await client.create<Omit<WorkspaceAttribute, 'id'>>(
WORKSPACE_TYPE,
attributes,
Expand Down Expand Up @@ -359,6 +405,10 @@ export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl {
}
}

if (permissions && !isValidatePermissions(permissions)) {
throw new Error(INVALID_PERMISSION_MODES_COMBINATION);
}

await client.create<Omit<WorkspaceAttribute, 'id'>>(WORKSPACE_TYPE, attributes, {
id,
permissions,
Expand Down