diff --git a/packages/discussions/src/utils/channel-permission.ts b/packages/discussions/src/utils/channel-permission.ts index 1c4babff9eb..b93bc9f5837 100644 --- a/packages/discussions/src/utils/channel-permission.ts +++ b/packages/discussions/src/utils/channel-permission.ts @@ -14,19 +14,14 @@ type PermissionsByAclCategoryMap = { [key in AclCategory]?: IChannelAclPermission[]; }; +enum ChannelAction { + READ_POSTS = "readPosts", + WRITE_POSTS = "writePosts", + MODERATE_CHANNEL = "moderateChannel", +} + export class ChannelPermission { private readonly ALLOWED_GROUP_MEMBER_TYPES = ["owner", "admin", "member"]; - private readonly ADMIN_GROUP_MEMBER_TYPES = ["owner", "admin"]; - - private readonly ALLOWED_ROLES_FOR_POSTING = Object.values(Role).filter( - (role) => role !== Role.READ - ); - private readonly ALLOWED_ROLES_FOR_MODERATION = [ - Role.MODERATE, - Role.MANAGE, - Role.OWNER, - ]; - private isChannelAclEmpty: boolean; private permissionsByCategory: PermissionsByAclCategoryMap; private channelCreator: string; @@ -43,8 +38,8 @@ export class ChannelPermission { }); } - canPostToChannel(user: IDiscussionsUser) { - if (this.canAnyUserWrite()) { + canPostToChannel(user: IDiscussionsUser): boolean { + if (this.canAnyUser(ChannelAction.WRITE_POSTS)) { return true; } @@ -53,14 +48,14 @@ export class ChannelPermission { } return ( - this.canAnyAuthenticatedUserWrite() || - this.isUserAWriteUser(user) || - this.isUserPartOfWriteGroup(user) || - this.isUserPartOfWriteOrg(user) + this.canAnyAuthenticatedUser(ChannelAction.WRITE_POSTS) || + this.canSomeUser(ChannelAction.WRITE_POSTS, user) || + this.canSomeUserGroup(ChannelAction.WRITE_POSTS, user) || + this.canSomeUserOrg(ChannelAction.WRITE_POSTS, user) ); } - canCreateChannel(user: IDiscussionsUser) { + canCreateChannel(user: IDiscussionsUser): boolean { if (this.isUserUnAuthenticated(user) || this.isChannelAclEmpty) { return false; } @@ -74,19 +69,96 @@ export class ChannelPermission { ); } - canModerateChannel(user: IDiscussionsUser) { + canModerateChannel(user: IDiscussionsUser): boolean { if (this.isUserUnAuthenticated(user)) { return false; } return ( user.username === this.channelCreator || - this.isUserAModeratorUser(user) || - this.isUserPartOfModeratorGroup(user) || - this.isUserPartOfModeratorOrg(user) + this.canSomeUser(ChannelAction.MODERATE_CHANNEL, user) || + this.canSomeUserGroup(ChannelAction.MODERATE_CHANNEL, user) || + this.canSomeUserOrg(ChannelAction.MODERATE_CHANNEL, user) + ); + } + + canReadChannel(user: IDiscussionsUser): boolean { + if (this.canAnyUser(ChannelAction.READ_POSTS)) { + return true; + } + + if (this.isUserUnAuthenticated(user)) { + return false; + } + + return ( + this.canAnyAuthenticatedUser(ChannelAction.READ_POSTS) || + this.canSomeUser(ChannelAction.READ_POSTS, user) || + this.canSomeUserGroup(ChannelAction.READ_POSTS, user) || + this.canSomeUserOrg(ChannelAction.READ_POSTS, user) ); } + private canAnyUser(action: ChannelAction): boolean { + const anonymousUserRole = + this.permissionsByCategory[AclCategory.ANONYMOUS_USER]?.[0].role; + + return channelActionLookup(action).includes(anonymousUserRole); + } + + private canAnyAuthenticatedUser(action: ChannelAction): boolean { + const role = + this.permissionsByCategory[AclCategory.AUTHENTICATED_USER]?.[0].role; + return channelActionLookup(action).includes(role); + } + + private canSomeUser(action: ChannelAction, user: IDiscussionsUser) { + const userPermissions = this.permissionsByCategory[AclCategory.USER] ?? []; + const username = user.username; + + return userPermissions.some((permission) => { + const { role, key } = permission; + + return key === username && channelActionLookup(action).includes(role); + }); + } + + private canSomeUserGroup(action: ChannelAction, user: IDiscussionsUser) { + const groupAccessControls = + this.permissionsByCategory[AclCategory.GROUP] ?? []; + + const userGroupsById = this.mapUserGroupsById(user.groups); + + return groupAccessControls.some((permission) => { + const group = userGroupsById[permission.key]; + + if (!group || !isGroupDiscussable(group)) { + return false; + } + + return ( + doesPermissionAllowGroupMemberType(permission, group) && + channelActionLookup(action).includes(permission.role) + ); + }); + } + + private canSomeUserOrg(action: ChannelAction, user: IDiscussionsUser) { + const orgPermissions = this.permissionsByCategory[AclCategory.ORG] ?? []; + + return orgPermissions.some((permission) => { + const { key } = permission; + if (permission.key !== user.orgId) { + return false; + } + + return ( + doesPermissionAllowOrgRole(permission, user.role) && + channelActionLookup(action).includes(permission.role) + ); + }); + } + /** * canCreateChannelHelpers */ @@ -118,7 +190,7 @@ export class ChannelPermission { return ( userGroup && this.isMemberTypeAuthorized(userGroup) && - this.isGroupDiscussable(userGroup) + isGroupDiscussable(userGroup) ); }); } @@ -153,90 +225,6 @@ export class ChannelPermission { return !userPermissions; } - private canAnyUserWrite() { - const role = - this.permissionsByCategory[AclCategory.ANONYMOUS_USER]?.[0].role; - return this.isAuthorizedToWritePost(role); - } - - private canAnyAuthenticatedUserWrite() { - const role = - this.permissionsByCategory[AclCategory.AUTHENTICATED_USER]?.[0].role; - return this.isAuthorizedToWritePost(role); - } - - private isUserAWriteUser(user: IDiscussionsUser) { - const userPermissions = this.permissionsByCategory[AclCategory.USER] ?? []; - const username = user.username; - - return userPermissions.some((permission) => { - const { role, key } = permission; - - return key === username && this.isAuthorizedToWritePost(role); - }); - } - - private isUserPartOfWriteGroup(user: IDiscussionsUser) { - const groupPermissions = - this.permissionsByCategory[AclCategory.GROUP] ?? []; - const userGroupsById = this.mapUserGroupsById(user.groups); - - return groupPermissions.some((permission) => { - const userGroup = userGroupsById[permission.key]; - - return ( - userGroup && - this.isMemberTypeAuthorized(userGroup) && - this.isGroupDiscussable(userGroup) && - (this.canAnyGroupMemberPost(permission) || - (this.isMemberTypeAdmin(userGroup) && this.canAdminsPost(permission))) - ); - }); - } - - private canAnyGroupMemberPost(permission: IChannelAclPermission) { - const { subCategory, role } = permission; - return ( - subCategory === AclSubCategory.MEMBER && - this.isAuthorizedToWritePost(role) - ); - } - - private canAdminsPost(permission: IChannelAclPermission) { - const { subCategory, role } = permission; - - return ( - subCategory === AclSubCategory.ADMIN && this.isAuthorizedToWritePost(role) - ); - } - - private isUserPartOfWriteOrg(user: IDiscussionsUser) { - const orgPermissions = this.permissionsByCategory[AclCategory.ORG] ?? []; - const { orgId: userOrgId } = user; - - return orgPermissions.some((permission) => { - const { key } = permission; - - return ( - key === userOrgId && - (this.canAnyOrgMemberPost(permission) || - (isOrgAdmin(user) && this.canAdminsPost(permission))) - ); - }); - } - - private canAnyOrgMemberPost(permission: IChannelAclPermission) { - const { subCategory, role } = permission; - return ( - subCategory === AclSubCategory.MEMBER && - this.isAuthorizedToWritePost(role) - ); - } - - private isAuthorizedToWritePost(role?: Role) { - return this.ALLOWED_ROLES_FOR_POSTING.includes(role); - } - private isUserUnAuthenticated(user: IDiscussionsUser) { return user.username === null || user.username === undefined; } @@ -254,86 +242,52 @@ export class ChannelPermission { } = userGroup; return this.ALLOWED_GROUP_MEMBER_TYPES.includes(memberType); } +} - private isMemberTypeAdmin(userGroup: IGroup) { - const { - userMembership: { memberType }, - } = userGroup; - return this.ADMIN_GROUP_MEMBER_TYPES.includes(memberType); - } - - private isGroupDiscussable(userGroup: IGroup) { - const { typeKeywords = [] } = userGroup; - return !typeKeywords.includes(CANNOT_DISCUSS); - } - - private isAuthorizedToModerate(role: Role) { - return this.ALLOWED_ROLES_FOR_MODERATION.includes(role); - } - - private isUserAModeratorUser(user: IDiscussionsUser) { - const userPermissions = this.permissionsByCategory[AclCategory.USER] ?? []; - const username = user.username; - - return userPermissions.some((permission) => { - const { role, key } = permission; - - return key === username && this.isAuthorizedToModerate(role); - }); - } - - private isUserPartOfModeratorGroup(user: IDiscussionsUser) { - const groupPermissions = - this.permissionsByCategory[AclCategory.GROUP] ?? []; - const userGroupsById = this.mapUserGroupsById(user.groups); - - return groupPermissions.some((permission) => { - const userGroup = userGroupsById[permission.key]; +function isGroupDiscussable(userGroup: IGroup): boolean { + const { typeKeywords = [] } = userGroup; + return !typeKeywords.includes(CANNOT_DISCUSS); +} - return ( - userGroup && - this.isMemberTypeAuthorized(userGroup) && - (this.canAnyGroupMemberModerate(permission) || - (this.isMemberTypeAdmin(userGroup) && - this.canAdminsModerate(permission))) - ); - }); +function doesPermissionAllowGroupMemberType( + permission: IChannelAclPermission, + group: IGroup +): boolean { + if ( + permission.category !== AclCategory.GROUP || + group.userMembership.memberType === "none" + ) { + return false; } - private canAnyGroupMemberModerate(permission: IChannelAclPermission) { - const { subCategory, role } = permission; - return ( - subCategory === AclSubCategory.MEMBER && this.isAuthorizedToModerate(role) - ); - } + return ( + // group owners and admins can do anything permissioned with SubCategory "member" + group.userMembership.memberType === "owner" || + group.userMembership.memberType === "admin" || + permission.subCategory === AclSubCategory.MEMBER + ); +} - private canAdminsModerate(permission: IChannelAclPermission) { - const { subCategory, role } = permission; +function doesPermissionAllowOrgRole( + permission: IChannelAclPermission, + orgRole: string +): boolean { + return ( + permission.category === AclCategory.ORG && + (permission.subCategory === AclSubCategory.MEMBER || + (permission.subCategory === "admin" && orgRole === "org_admin")) + ); +} - return ( - subCategory === AclSubCategory.ADMIN && this.isAuthorizedToModerate(role) - ); +function channelActionLookup(action: ChannelAction): Role[] { + if (action === ChannelAction.WRITE_POSTS) { + return [Role.WRITE, Role.READWRITE, Role.MODERATE, Role.MANAGE, Role.OWNER]; } - private isUserPartOfModeratorOrg(user: IDiscussionsUser) { - const orgPermissions = this.permissionsByCategory[AclCategory.ORG] ?? []; - const { orgId: userOrgId } = user; - - return orgPermissions.some((permission) => { - const { key } = permission; - - return ( - key === userOrgId && - (this.canAnyOrgMemberModerate(permission) || - (isOrgAdmin(user) && this.canAdminsModerate(permission))) - ); - }); + if (action === ChannelAction.MODERATE_CHANNEL) { + return [Role.MODERATE, Role.MANAGE, Role.OWNER]; } - private canAnyOrgMemberModerate(permission: IChannelAclPermission) { - const { subCategory, role } = permission; - return ( - subCategory === AclSubCategory.MEMBER && this.isAuthorizedToModerate(role) - ); - } + // default to read action + return [Role.READ, Role.READWRITE, Role.MODERATE, Role.MANAGE, Role.OWNER]; } diff --git a/packages/discussions/src/utils/channels/can-read-channel.ts b/packages/discussions/src/utils/channels/can-read-channel.ts new file mode 100644 index 00000000000..4e03340cc45 --- /dev/null +++ b/packages/discussions/src/utils/channels/can-read-channel.ts @@ -0,0 +1,80 @@ +import { IUser } from "@esri/arcgis-rest-auth"; +import { GroupMembership } from "@esri/arcgis-rest-portal"; +import { IChannel, IDiscussionsUser } from "../../types"; +import { reduceByGroupMembership } from "../platform"; +import { ChannelPermission } from "../channel-permission"; + +/** + * Utility to determine whether User can view channel posts and channel attributes + * + * @export + * @param {IChannel} channel + * @param {IUser} user + * @return {*} {boolean} + */ +export function canReadChannel( + channel: IChannel, + user: IUser | IDiscussionsUser = {} +): boolean { + const { channelAcl, creator } = channel; + + if (channelAcl) { + const channelPermission = new ChannelPermission(channelAcl, creator); + return channelPermission.canReadChannel(user); + } + + if (channel.access === "private") { + // ensure user is member of at least one group + return intersectGroups(["member", "owner", "admin"])(user, channel); + } + + if (channel.access === "org") { + return ( + intersectGroups(["member", "owner", "admin"])(user, channel) || + isChannelOrgMember(channel, user) + ); + } + + // public channel + return true; +} + +/** + * Utility (deprecated) to determine whether User can view posts belonging to Channel + * + * @export + * @param {IChannel} channel + * @param {IUser} user + * @return {*} {boolean} + */ +export function canReadFromChannel( + channel: IChannel, + user: IUser | IDiscussionsUser = {} +): boolean { + return canReadChannel(channel, user); +} + +function intersectGroups( + membershipTypes: GroupMembership[] +): (arg0: IUser, arg1: IChannel) => boolean { + return (user: IUser | IDiscussionsUser, channel: IChannel): boolean => { + const { groups: sharedGroups = [] } = channel; + const { groups: userGroups = [] } = user; + const eligibleUserGroups = userGroups.reduce( + reduceByGroupMembership(membershipTypes), + [] + ); + const method = "some"; + return sharedGroups[method]( + (group) => eligibleUserGroups.indexOf(group) > -1 + ); + }; +} + +function isChannelOrgMember( + channel: IChannel, + user: IUser | IDiscussionsUser +): boolean { + // orgs.length = 1 until collaboration/discussion between many orgs is ideated + return channel.orgs.length === 1 && channel.orgs.indexOf(user.orgId) > -1; +} diff --git a/packages/discussions/src/utils/channels/index.ts b/packages/discussions/src/utils/channels/index.ts index 10fdf0ba48b..f482aa860f5 100644 --- a/packages/discussions/src/utils/channels/index.ts +++ b/packages/discussions/src/utils/channels/index.ts @@ -1,61 +1,4 @@ -import { IUser } from "@esri/arcgis-rest-auth"; -import { GroupMembership } from "@esri/arcgis-rest-portal"; -import { IChannel, IDiscussionsUser } from "../../types"; -import { reduceByGroupMembership } from "../platform"; - export { canPostToChannel } from "./can-post-to-channel"; export { canCreateChannel } from "./can-create-channel"; export { canModifyChannel } from "./can-modify-channel"; - -function intersectGroups( - membershipTypes: GroupMembership[] -): (arg0: IUser, arg1: IChannel) => boolean { - return (user: IUser | IDiscussionsUser, channel: IChannel): boolean => { - const { groups: sharedGroups = [] } = channel; - const { groups: userGroups = [] } = user; - const eligibleUserGroups = userGroups.reduce( - reduceByGroupMembership(membershipTypes), - [] - ); - const method = "some"; - return sharedGroups[method]( - (group) => eligibleUserGroups.indexOf(group) > -1 - ); - }; -} - -function isChannelOrgMember( - channel: IChannel, - user: IUser | IDiscussionsUser -): boolean { - // orgs.length = 1 until collaboration/discussion between many orgs is ideated - return channel.orgs.length === 1 && channel.orgs.indexOf(user.orgId) > -1; -} - -/** - * Utility to determine whether User can view posts belonging to Channel - * - * @export - * @param {IChannel} channel - * @param {IUser} user - * @return {*} {boolean} - */ -export function canReadFromChannel( - channel: IChannel, - user: IUser | IDiscussionsUser = {} -): boolean { - if (channel.access === "private") { - // ensure user is member of at least one group - return intersectGroups(["member", "owner", "admin"])(user, channel); - } - - if (channel.access === "org") { - return ( - intersectGroups(["member", "owner", "admin"])(user, channel) || - isChannelOrgMember(channel, user) - ); - } - - // public channel - return true; -} +export { canReadChannel, canReadFromChannel } from "./can-read-channel"; diff --git a/packages/discussions/test/utils/channel-permission.test.ts b/packages/discussions/test/utils/channel-permission.test.ts index 21b6291e4d5..961eaf124af 100644 --- a/packages/discussions/test/utils/channel-permission.test.ts +++ b/packages/discussions/test/utils/channel-permission.test.ts @@ -12,6 +12,14 @@ import { CANNOT_DISCUSS } from "../../src/utils/constants"; const ALLOWED_GROUP_ROLES = Object.freeze(["owner", "admin", "member"]); const ADMIN_GROUP_MEMBER_TYPES = Object.freeze(["owner", "admin"]); +const ALLOWED_ROLES_FOR_READING = Object.freeze([ + Role.READ, + Role.READWRITE, + Role.MANAGE, + Role.MODERATE, + Role.OWNER, +]); + const ALLOWED_ROLES_FOR_POSTING = Object.freeze([ Role.WRITE, Role.READWRITE, @@ -831,7 +839,10 @@ describe("ChannelPermission class", () => { groups: [buildGroup(groupId1, memberType)], // member in groupId1 }); - const channelPermission = new ChannelPermission(channelAcl, "foo"); + const channelPermission = new ChannelPermission( + channelAcl, + channelCreator + ); expect(channelPermission.canModerateChannel(user)).toBe(true); }); @@ -911,7 +922,10 @@ describe("ChannelPermission class", () => { groups: [buildGroup(groupId1, memberType)], // admin or owner in groupId1 }); - const channelPermission = new ChannelPermission(channelAcl, "foo"); + const channelPermission = new ChannelPermission( + channelAcl, + channelCreator + ); expect(channelPermission.canModerateChannel(user)).toBe(true); }); @@ -1080,4 +1094,836 @@ describe("ChannelPermission class", () => { }); }); }); + + describe("canPostToChannel", () => { + describe("all permission cases", () => { + it("returns false if user logged in and channel permissions are empty", async () => { + const user = buildUser(); + const channelAcl = [] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + + it("returns false if user not logged in and channel permissions are empty", async () => { + const user = buildUser({ username: null }); + const channelAcl = [] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + }); + + describe("Anonymous User Permissions", () => { + it(`returns true if anonymous permission defined and role is allowed`, () => { + const user = buildUser({ username: null }); + + ALLOWED_ROLES_FOR_POSTING.forEach((allowedRole) => { + const channelAcl = [ + { category: AclCategory.ANONYMOUS_USER, role: allowedRole }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(true); + }); + }); + + it("returns false if anonymous permission defined but role is read", () => { + const user = buildUser({ username: null }); + const channelAcl = [ + { category: AclCategory.ANONYMOUS_USER, role: Role.READ }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + }); + + describe("Authenticated User Permissions", () => { + it(`returns true if authenticated permission defined, user logged in, and role is allowed`, async () => { + const user = buildUser(); + + ALLOWED_ROLES_FOR_POSTING.forEach((allowedRole) => { + const channelAcl = [ + { category: AclCategory.AUTHENTICATED_USER, role: allowedRole }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(true); + }); + }); + + it("returns false if authenticated permission defined, user logged in, and role is read", async () => { + const user = buildUser(); + const channelAcl = [ + { category: AclCategory.AUTHENTICATED_USER, role: Role.READ }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + + it("returns false if authenticated permission defined and user is not logged in", async () => { + const user = buildUser({ username: null }); + const channelAcl = [ + { category: AclCategory.AUTHENTICATED_USER, role: Role.READWRITE }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + }); + + describe("Group Permissions", () => { + it("returns true if user is group member in group permission list and role is allowed", async () => { + ALLOWED_ROLES_FOR_POSTING.forEach((allowedRole) => { + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: allowedRole, // members write + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.READ, + }, + ] as IChannelAclPermission[]; + + ALLOWED_GROUP_ROLES.forEach((memberType) => { + const user = buildUser({ + orgId: orgId1, + groups: [buildGroup(groupId1, memberType)], // member in groupId1 + }); + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(true); + }); + }); + }); + + it("returns false if user is group member in group permission list and role is NOT allowed", async () => { + const user = buildUser(); // member in groupId1 + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.READ, // members read + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.READ, + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + + it("returns false if user is group member in group permission list, role is allowed, but userMemberType is none", async () => { + const user = buildUser({ + groups: [buildGroup(groupId1, "none")], // none in groupId1 + }); + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.READWRITE, // members read + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.READWRITE, // admins read + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + + it("returns true if user is group owner/admin in group permission list and role is allowed", async () => { + ALLOWED_ROLES_FOR_POSTING.forEach((allowedRole) => { + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.READ, + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: allowedRole, // admins write + }, + ] as IChannelAclPermission[]; + + ["owner", "admin"].forEach((memberType) => { + const user = buildUser({ + orgId: orgId1, + groups: [buildGroup(groupId1, memberType)], // admin in groupId1 + }); + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(true); + }); + }); + }); + + it("returns false if user is group owner/admin in group permission list and role is NOT allowed", async () => { + const user = buildUser(); // admin in groupId2 + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId2, + role: Role.READ, + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId2, + role: Role.READ, // admins read + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + + it("returns true if user is group member of at least one group in permissions list that is discussable", async () => { + const user = buildUser({ + orgId: orgId1, + groups: [ + buildGroup(groupId1, "member"), // member in groupId1 + buildGroup(groupId2, "member", [CANNOT_DISCUSS]), // member in groupId2 + ], + }); + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.READWRITE, // members write + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.READ, + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId2, + role: Role.READWRITE, // members write, group CANNOT_DISCUSS + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId2, + role: Role.READ, + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(true); + }); + + it("returns false if user is group member in permissions list but the group is not discussable", async () => { + const user = buildUser({ + orgId: orgId1, + groups: [ + buildGroup(groupId1, "member", [CANNOT_DISCUSS]), // member in groupId1 + ], + }); + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.READWRITE, // members write + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.READ, + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + + it("returns false if user is group admin but group is not in permissions list", async () => { + const user = buildUser({ + orgId: orgId1, + groups: [ + buildGroup("unknownGroupId", "admin"), // admin in unknownGroupId + ], + }); + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.READWRITE, // members write + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.READWRITE, // admin write + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + }); + + describe("Org Permissions", () => { + it("returns true if user is org member in permissions list and member role is allowed", async () => { + const user = buildUser(); + + ALLOWED_ROLES_FOR_POSTING.forEach((allowedRole) => { + const channelAcl = [ + { + category: AclCategory.ORG, + subCategory: AclSubCategory.MEMBER, + key: user.orgId, + role: allowedRole, // members write + }, + { + category: AclCategory.ORG, + subCategory: AclSubCategory.ADMIN, + key: user.orgId, + role: Role.READ, // admin read + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "joker"); + + expect(channelPermission.canPostToChannel(user)).toBe(true); + }); + }); + + it("returns true if user is org_admin in permissions list and admin role is allowed", async () => { + const user = buildUser({ role: "org_admin" }); + + ALLOWED_ROLES_FOR_POSTING.forEach((allowedRole) => { + const channelAcl = [ + { + category: AclCategory.ORG, + subCategory: AclSubCategory.MEMBER, + key: user.orgId, + role: Role.READ, // members read + }, + { + category: AclCategory.ORG, + subCategory: AclSubCategory.ADMIN, + key: user.orgId, + role: allowedRole, // admin write + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(true); + }); + }); + + it("returns false if user is not in the permissions org", async () => { + const user = buildUser({ orgId: "unknownOrgId" }); + const channelAcl = [ + { + category: AclCategory.ORG, + subCategory: AclSubCategory.MEMBER, + key: orgId1, + role: Role.READ, // members read + }, + { + category: AclCategory.ORG, + subCategory: AclSubCategory.ADMIN, + key: orgId1, + role: Role.READWRITE, // admin write + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + }); + + describe("User Permissions", () => { + it("returns true if user is in permissions list and role is allowed", () => { + const user = buildUser(); + + ALLOWED_ROLES_FOR_POSTING.forEach((allowedRole) => { + const channelAcl = [ + { + category: AclCategory.USER, + key: user.username, + role: allowedRole, + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(true); + }); + }); + + it("returns false if user is in permissions list but role is read", () => { + const user = buildUser(); + const channelAcl = [ + { category: AclCategory.USER, key: user.username, role: Role.READ }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canPostToChannel(user)).toBe(false); + }); + }); + }); + + describe("canReadChannel", () => { + describe("no channel permissions defined", () => { + it("returns false if user logged in and channel permissions are empty", async () => { + const user = buildUser(); + const channelAcl = [] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + + it("returns false if user not logged in and channel permissions are empty", async () => { + const user = buildUser({ username: null }); + const channelAcl = [] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + }); + + describe("Anonymous User Permissions", () => { + it(`returns true if anonymous permission defined and role is allowed`, () => { + const user = buildUser({ username: null }); + + ALLOWED_ROLES_FOR_READING.forEach((allowedRole) => { + const channelAcl = [ + { category: AclCategory.ANONYMOUS_USER, role: allowedRole }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(true); + }); + }); + + it("returns false if anonymous permission defined but role is write", () => { + const user = buildUser({ username: null }); + const channelAcl = [ + { category: AclCategory.ANONYMOUS_USER, role: Role.WRITE }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + }); + + describe("Authenticated User Permissions", () => { + it(`returns true if authenticated permission defined, user logged in, and role is allowed`, async () => { + const user = buildUser(); + + ALLOWED_ROLES_FOR_READING.forEach((allowedRole) => { + const channelAcl = [ + { category: AclCategory.AUTHENTICATED_USER, role: allowedRole }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(true); + }); + }); + + it("returns false if authenticated permission defined, user logged in, and role is write", async () => { + const user = buildUser(); + const channelAcl = [ + { category: AclCategory.AUTHENTICATED_USER, role: Role.WRITE }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + + it("returns false if authenticated permission defined and user is not logged in", async () => { + const user = buildUser({ username: null }); + const channelAcl = [ + { category: AclCategory.AUTHENTICATED_USER, role: Role.READWRITE }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + }); + + describe("Group Permissions", () => { + it("returns true if user is group member in group permission list and role is allowed", async () => { + ALLOWED_ROLES_FOR_READING.forEach((allowedRole) => { + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: allowedRole, // members write + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.READ, + }, + ] as IChannelAclPermission[]; + + ALLOWED_GROUP_ROLES.forEach((memberType) => { + const user = buildUser({ + orgId: orgId1, + groups: [buildGroup(groupId1, memberType)], // member in groupId1 + }); + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(true); + }); + }); + }); + + it("returns false if user is group member in group permission list and role is NOT allowed", async () => { + const user = buildUser(); // member in groupId1 + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.WRITE, + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.WRITE, + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + + it("returns false if user is group member in group permission list, role is allowed, but userMemberType is none", async () => { + const user = buildUser({ + groups: [buildGroup(groupId1, "none")], // none in groupId1 + }); + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.READWRITE, // members read + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.READWRITE, // admins read + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + + it("returns true if user is group owner/admin in group permission list and role is allowed", async () => { + ALLOWED_ROLES_FOR_READING.forEach((allowedRole) => { + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.READ, + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: allowedRole, // admins write + }, + ] as IChannelAclPermission[]; + + ["owner", "admin"].forEach((memberType) => { + const user = buildUser({ + orgId: orgId1, + groups: [buildGroup(groupId1, memberType)], // admin in groupId1 + }); + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(true); + }); + }); + }); + + it("returns false if user is group owner/admin in group permission list and role is NOT allowed", async () => { + const user = buildUser(); // admin in groupId2 + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId2, + role: Role.WRITE, + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId2, + role: Role.WRITE, + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + + it("returns true if user is group member of at least one group in permissions list that is discussable", async () => { + const user = buildUser({ + orgId: orgId1, + groups: [ + buildGroup(groupId1, "member"), // member in groupId1 + buildGroup(groupId2, "member", [CANNOT_DISCUSS]), // member in groupId2 + ], + }); + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.READWRITE, // members write + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.READ, + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId2, + role: Role.READWRITE, // members write, group CANNOT_DISCUSS + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId2, + role: Role.READ, + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(true); + }); + + it("returns false if user is group member in permissions list but the group is not discussable", async () => { + const user = buildUser({ + orgId: orgId1, + groups: [ + buildGroup(groupId1, "member", [CANNOT_DISCUSS]), // member in groupId1 + ], + }); + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.READWRITE, // members write + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.WRITE, + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + + it("returns false if user is group admin but group is not in permissions list", async () => { + const user = buildUser({ + orgId: orgId1, + groups: [ + buildGroup("unknownGroupId", "admin"), // admin in unknownGroupId + ], + }); + const channelAcl = [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: groupId1, + role: Role.READWRITE, // members write + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.ADMIN, + key: groupId1, + role: Role.READWRITE, // admin write + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + }); + + describe("Org Permissions", () => { + it("returns true if user is org member in permissions list and member role is allowed", async () => { + const user = buildUser(); + + ALLOWED_ROLES_FOR_READING.forEach((allowedRole) => { + const channelAcl = [ + { + category: AclCategory.ORG, + subCategory: AclSubCategory.MEMBER, + key: user.orgId, + role: allowedRole, // members write + }, + { + category: AclCategory.ORG, + subCategory: AclSubCategory.ADMIN, + key: user.orgId, + role: Role.READ, // admin read + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "joker"); + + expect(channelPermission.canReadChannel(user)).toBe(true); + }); + }); + + it("returns true if user is org_admin in permissions list and admin role is allowed", async () => { + const user = buildUser({ role: "org_admin" }); + + ALLOWED_ROLES_FOR_READING.forEach((allowedRole) => { + const channelAcl = [ + { + category: AclCategory.ORG, + subCategory: AclSubCategory.MEMBER, + key: user.orgId, + role: Role.READ, // members read + }, + { + category: AclCategory.ORG, + subCategory: AclSubCategory.ADMIN, + key: user.orgId, + role: allowedRole, // admin write + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(true); + }); + }); + + it("returns false if user is not in the permissions org", async () => { + const user = buildUser({ orgId: "unknownOrgId" }); + const channelAcl = [ + { + category: AclCategory.ORG, + subCategory: AclSubCategory.MEMBER, + key: orgId1, + role: Role.WRITE, + }, + { + category: AclCategory.ORG, + subCategory: AclSubCategory.ADMIN, + key: orgId1, + role: Role.READWRITE, + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + }); + + describe("User Permissions", () => { + it("returns true if user is in permissions list and role is allowed", () => { + const user = buildUser(); + + ALLOWED_ROLES_FOR_READING.forEach((allowedRole) => { + const channelAcl = [ + { + category: AclCategory.USER, + key: user.username, + role: allowedRole, + }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(true); + }); + }); + + it("returns false if user is in permissions list but role is write", () => { + const user = buildUser(); + const channelAcl = [ + { category: AclCategory.USER, key: user.username, role: Role.WRITE }, + ] as IChannelAclPermission[]; + + const channelPermission = new ChannelPermission(channelAcl, "foo"); + + expect(channelPermission.canReadChannel(user)).toBe(false); + }); + }); + }); }); diff --git a/packages/discussions/test/utils/channels/can-read-channel.test.ts b/packages/discussions/test/utils/channels/can-read-channel.test.ts new file mode 100644 index 00000000000..1de8bb1e445 --- /dev/null +++ b/packages/discussions/test/utils/channels/can-read-channel.test.ts @@ -0,0 +1,219 @@ +import { IGroup, IUser } from "@esri/arcgis-rest-types"; +import { + AclCategory, + IChannel, + IDiscussionsUser, + Role, + SharingAccess, +} from "../../../src/types"; +import { ChannelPermission } from "../../../src/utils/channel-permission"; +import { canReadChannel } from "../../../src/utils/channels"; + +const groupId1 = "aaa"; +const groupId2 = "bbb"; +const groupId3 = "baz"; + +const orgId1 = "3ef"; +const orgId2 = "4dc"; +const orgId3 = "zzz"; + +const fakeUser = (props: any = { username: "jdoe", orgId: "3ef" }) => + props as IUser; +const fakeGroup = (id: string, memberType: string) => + ({ id, userMembership: { memberType } } as IGroup); +const fakeChannel = (props: any) => props as IChannel; + +function buildUser(overrides = {}) { + const defaultUser = { + username: "john", + orgId: orgId1, + role: "org_user", + groups: [buildGroup(groupId1, "member"), buildGroup(groupId2, "admin")], + }; + + return { ...defaultUser, ...overrides } as IDiscussionsUser; +} + +function buildGroup(id: string, memberType: string, typeKeywords?: string[]) { + return { + id, + userMembership: { memberType }, + typeKeywords, + } as any as IGroup; +} + +describe("canReadChannel", () => { + describe("with channelAcl", () => { + let canReadChannelSpy: jasmine.Spy; + + beforeAll(() => { + canReadChannelSpy = spyOn(ChannelPermission.prototype, "canReadChannel"); + }); + + beforeEach(() => { + canReadChannelSpy.calls.reset(); + }); + + it("return true if channelPermission.canReadChannel is true", () => { + canReadChannelSpy.and.callFake(() => true); + + const user = buildUser(); + const channel = { + channelAcl: [{ category: AclCategory.ANONYMOUS_USER, role: Role.READ }], + } as IChannel; + + expect(canReadChannel(channel, user)).toBe(true); + + expect(canReadChannelSpy.calls.count()).toBe(1); + const [arg] = canReadChannelSpy.calls.allArgs()[0]; // arg for 1st call + expect(arg).toBe(user); + }); + + it("return false if channelPermission.canReadChannel is false", () => { + canReadChannelSpy.and.callFake(() => false); + + const user = buildUser(); + const channel = { + channelAcl: [ + { category: AclCategory.ANONYMOUS_USER, role: Role.WRITE }, + ], + } as IChannel; + + expect(canReadChannel(channel, user)).toBe(false); + + expect(canReadChannelSpy.calls.count()).toBe(1); + }); + }); + + describe("with legacy permissions", () => { + let user = fakeUser(); + let user3 = fakeUser(); + + beforeEach(() => { + // org1 member, member in groupId1, admin in groupId2, owner in groupId3 + user = fakeUser({ + username: "jdoe", + orgId: orgId1, + groups: [ + fakeGroup(groupId1, "member"), + fakeGroup(groupId2, "admin"), + fakeGroup(groupId3, "owner"), + ], + }); + + // org3 member, member in groupId1, admin in groupId2, owner in groupId3 + user3 = fakeUser({ + username: "mrBurrito", + orgId: orgId3, + groups: [ + fakeGroup(groupId1, "member"), + fakeGroup(groupId2, "admin"), + fakeGroup(groupId3, "owner"), + ], + }); + }); + describe("Private channel", () => { + it("returns true for user that is member of channel group", () => { + const channel = fakeChannel({ + access: SharingAccess.PRIVATE, + orgs: [orgId1], + groups: [groupId1], + }); + expect(canReadChannel(channel, user)).toBeTruthy(); + }); + it("returns true for user that is admin of channel group", () => { + const channel = fakeChannel({ + access: SharingAccess.PRIVATE, + orgs: [orgId1], + groups: [groupId2], + }); + expect(canReadChannel(channel, user)).toBeTruthy(); + }); + it("returns true for user that is owner of channel group", () => { + const channel = fakeChannel({ + access: SharingAccess.PRIVATE, + orgs: [orgId1], + groups: [groupId3], + }); + expect(canReadChannel(channel, user)).toBeTruthy(); + }); + it("returns false for user that is not in channel group", () => { + const channel = fakeChannel({ + access: SharingAccess.PRIVATE, + orgs: [orgId1], + groups: ["unknown"], + }); + expect(canReadChannel(channel, user)).toBeFalsy(); + }); + it("returns false undefined user", () => { + const channel = fakeChannel({ + access: SharingAccess.PRIVATE, + orgs: [orgId1], + groups: ["unknown"], + }); + expect(canReadChannel(channel)).toBeFalsy(); + }); + it("returns false for user that has no groups", () => { + const userNoGroups = fakeUser(); + const channel = fakeChannel({ + access: SharingAccess.PRIVATE, + orgs: [orgId1], + groups: ["unknown"], + }); + expect(canReadChannel(channel, userNoGroups)).toBeFalsy(); + }); + }); + + describe("Org channel", () => { + it("returns true for user that is member of channel group but not in org", () => { + const channel = fakeChannel({ + access: SharingAccess.ORG, + orgs: [orgId1], // user3 not in this org + groups: [groupId1], + }); + expect(canReadChannel(channel, user3)).toBeTruthy(); + }); + it("returns true for user that is admin of channel group but not in org", () => { + const channel = fakeChannel({ + access: SharingAccess.ORG, + orgs: [orgId1], // user3 not in this org + groups: [groupId2], + }); + expect(canReadChannel(channel, user3)).toBeTruthy(); + }); + it("returns true for user that is owner of channel group but not in org", () => { + const channel = fakeChannel({ + access: SharingAccess.ORG, + orgs: [orgId1], // user3 not in this org + groups: [groupId3], + }); + expect(canReadChannel(channel, user3)).toBeTruthy(); + }); + + it("returns true for user that not in channel groups but is member of channel org", () => { + const channel = fakeChannel({ + access: SharingAccess.ORG, + orgs: [orgId1], + }); + expect(canReadChannel(channel, user)).toBeTruthy(); + }); + it("returns false for user that is not in channel groups and not member of channel org", () => { + const channel = fakeChannel({ + access: SharingAccess.ORG, + orgs: [orgId2], // user not in this org + }); + expect(canReadChannel(channel, user)).toBeFalsy(); + }); + }); + + describe("Public channel", () => { + it("returns true for public channel access", () => { + const channel = fakeChannel({ + access: SharingAccess.PUBLIC, + orgs: [orgId2], + }); + expect(canReadChannel(channel, user)).toBeTruthy(); + }); + }); + }); +});