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

Granular level topic gating #9512

Merged
merged 30 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b16c492
Added `GroupTopicPermissions` entity
mzparacha Oct 10, 2024
edf786a
Added logic to create group with topic level permissions
mzparacha Oct 10, 2024
de7d332
Added logic to update group with topic level permissions
mzparacha Oct 10, 2024
abd1584
Added logic to show group gated topic level permissions
mzparacha Oct 10, 2024
e5f022c
Updated `/refresh-memberships` response to include topic level permis…
mzparacha Oct 10, 2024
161bae8
Block specific thread/comment actions if required conditions are not …
mzparacha Oct 10, 2024
82db249
Abstracted topic gating membership logic into global hook
mzparacha Oct 10, 2024
98e5103
Updated UI to show blocked action status on thread/comment actions if…
mzparacha Oct 10, 2024
a4f7e7a
Added migration to allows existing groups/topics to have full topic l…
mzparacha Oct 11, 2024
12ff29c
Fix migration
mzparacha Oct 11, 2024
74c20f3
Fix group card topics styling
mzparacha Oct 11, 2024
ba6be00
Fix CI
mzparacha Oct 11, 2024
adc0047
Merge branch 'master' into malik.9063.granular-topic-gating
mzparacha Oct 11, 2024
e8e0779
Fix lint
mzparacha Oct 11, 2024
77840a1
Allow community admins to bypass topic gating restrictions
mzparacha Oct 11, 2024
1f42e8c
Fixed failing unit tests
mzparacha Oct 11, 2024
7f553b3
Rename prop type
mzparacha Oct 11, 2024
d230070
Unified `GroupTopicPermissions` model into `GroupPermissions`
mzparacha Oct 15, 2024
e178ebf
Removed unused permission
mzparacha Oct 15, 2024
d4d3934
Updated community lifecycle test
mzparacha Oct 15, 2024
df99245
Updated thread lifecycle test
mzparacha Oct 15, 2024
b1a163f
Merge branch 'master' into malik.9063.granular-topic-gating
mzparacha Oct 15, 2024
7852a3e
Create group in bulks
mzparacha Oct 15, 2024
1fb9fa8
Added back update poll permission
mzparacha Oct 15, 2024
b193e6d
Revert bulk group create
mzparacha Oct 15, 2024
f1fd855
Fix type
mzparacha Oct 15, 2024
af89214
Fix model test
mzparacha Oct 15, 2024
7fcc815
Fix migration
mzparacha Oct 16, 2024
a2009af
Added bulk create for group topic permissions
mzparacha Oct 17, 2024
669480e
Merge branch 'master' into malik.9063.granular-topic-gating
mzparacha Oct 17, 2024
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
4 changes: 3 additions & 1 deletion libs/model/src/comment/CreateComment.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export function CreateComment(): Command<
return {
...schemas.CreateComment,
auth: [
isAuthorized({ action: schemas.PermissionEnum.CREATE_COMMENT }),
isAuthorized({
action: schemas.PermissionEnum.CREATE_COMMENT,
}),
verifyCommentSignature,
],
body: async ({ actor, payload, auth }) => {
Expand Down
4 changes: 3 additions & 1 deletion libs/model/src/comment/CreateCommentReaction.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export function CreateCommentReaction(): Command<
return {
...schemas.CreateCommentReaction,
auth: [
isAuthorized({ action: schemas.PermissionEnum.CREATE_COMMENT_REACTION }),
isAuthorized({
action: schemas.PermissionEnum.CREATE_COMMENT_REACTION,
}),
verifyReactionSignature,
],
body: async ({ payload, actor, auth }) => {
Expand Down
16 changes: 15 additions & 1 deletion libs/model/src/community/CreateGroup.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function CreateGroup(): Command<

const topics = await models.Topic.findAll({
where: {
id: { [Op.in]: payload.topics || [] },
id: { [Op.in]: payload.topics?.map((t) => t.id) || [] },
community_id,
},
});
Expand Down Expand Up @@ -70,6 +70,20 @@ export function CreateGroup(): Command<
transaction,
},
);

if (group.id) {
// add topic level interaction permissions for current group
const groupPermissions = (payload.topics || []).map((t) => ({
group_id: group.id!,
topic_id: t.id,
allowed_actions: sequelize.literal(
`ARRAY[${t.permissions.map((p) => `'${p}'`).join(', ')}]::"enum_GroupPermissions_allowed_actions"[]`,
) as unknown as schemas.PermissionEnum[],
}));
await models.GroupPermission.bulkCreate(groupPermissions, {
transaction,
});
}
}
return group.toJSON();
},
Expand Down
22 changes: 21 additions & 1 deletion libs/model/src/community/UpdateGroup.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function UpdateGroup(): Command<

const topics = await models.Topic.findAll({
where: {
id: { [Op.in]: payload.topics || [] },
id: { [Op.in]: payload.topics?.map((t) => t.id) || [] },
community_id,
},
});
Expand Down Expand Up @@ -90,6 +90,26 @@ export function UpdateGroup(): Command<
transaction,
},
);

// update topic level interaction permissions for current group
await Promise.all(
timolegros marked this conversation as resolved.
Show resolved Hide resolved
(payload.topics || [])?.map(async (t) => {
if (group.id) {
await models.GroupPermission.update(
{
allowed_actions: t.permissions,
},
{
where: {
group_id: group_id,
topic_id: t.id,
},
transaction,
},
);
}
}),
);
}

return group.toJSON();
Expand Down
37 changes: 25 additions & 12 deletions libs/model/src/middleware/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
type Context,
type Handler,
} from '@hicommonwealth/core';
import { Group, GroupPermissionAction } from '@hicommonwealth/schemas';
import {
Group,
GroupPermissionAction,
} from '@hicommonwealth/schemas';
import { Role } from '@hicommonwealth/shared';
import { Op, QueryTypes } from 'sequelize';
import { ZodSchema, z } from 'zod';
Expand Down Expand Up @@ -182,7 +185,7 @@ async function buildAuth(
/**
* Checks if actor passes a set of requirements and grants access for all groups of the given topic
*/
async function isTopicMember(
async function hasTopicInteractionPermissions(
actor: Actor,
auth: AuthContext,
action: GroupPermissionAction,
Expand All @@ -194,39 +197,45 @@ async function isTopicMember(

if (auth.topic.group_ids?.length === 0) return;

// check if user has permission to perform "action" in 'topic_id'
// the 'topic_id' can belong to any group where user has membership
// the group with 'topic_id' having higher permissions will take precedence
mzparacha marked this conversation as resolved.
Show resolved Hide resolved
const groups = await models.sequelize.query<
z.infer<typeof Group> & {
allowed_actions?: GroupPermissionAction[];
}
>(
`
SELECT g.*, gp.allowed_actions
SELECT
g.*,
gp.allowed_actions as allowed_actions
FROM "Groups" as g
LEFT JOIN "GroupPermissions" gp ON g.id = gp.group_id
WHERE g.community_id = :community_id AND g.id IN (:group_ids);
LEFT JOIN "GroupPermissions" gp ON g.id = gp.group_id AND gp.topic_id = :topic_id
WHERE g.community_id = :community_id
`,
{
type: QueryTypes.SELECT,
raw: true,
replacements: {
community_id: auth.topic.community_id,
group_ids: auth.topic.group_ids,
topic_id: auth.topic.id,
},
},
);

// There are 2 cases here. We either have the old group permission system where the group doesn't have
// any allowed_actions, or we have the new fine-grained permission system where the action must be in
// the allowed_actions list.
const allowed = groups.filter(
// any group_allowed_actions, or we have the new fine-grained permission system where the action must be in
// the group_allowed_actions list.
const allowedGroupActions = groups.filter(
(g) => !g.allowed_actions || g.allowed_actions.includes(action),
);
if (!allowed.length!) throw new NonMember(actor, auth.topic.name, action);
if (!allowedGroupActions.length!)
throw new NonMember(actor, auth.topic.name, action);

// check membership for all groups of topic
const memberships = await models.Membership.findAll({
where: {
group_id: { [Op.in]: allowed.map((g) => g.id!) },
group_id: { [Op.in]: allowedGroupActions.map((g) => g.id!) },
address_id: auth.address!.id,
},
include: [
Expand Down Expand Up @@ -295,7 +304,11 @@ export function isAuthorized({

if (action) {
// waterfall stops here after validating the action
await isTopicMember(ctx.actor, auth, action);
await hasTopicInteractionPermissions(
ctx.actor,
auth,
action,
);
return;
}

Expand Down
14 changes: 12 additions & 2 deletions libs/model/src/models/associations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,13 @@ export const buildAssociations = (db: DB) => {
asMany: 'threads',
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
}).withMany(db.ContestTopic, { asMany: 'contest_topics' });
})
.withMany(db.ContestTopic, { asMany: 'contest_topics' })
.withMany(db.GroupPermission, {
mzparacha marked this conversation as resolved.
Show resolved Hide resolved
foreignKey: 'topic_id',
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
});

db.Thread.withMany(db.Poll)
.withMany(db.ContestAction, {
Expand Down Expand Up @@ -130,7 +136,11 @@ export const buildAssociations = (db: DB) => {
onDelete: 'CASCADE',
});

db.Group.withMany(db.GroupPermission);
db.Group.withMany(db.GroupPermission, {
foreignKey: 'group_id',
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
});

// Many-to-many associations (cross-references)
db.Membership.withManyToMany(
Expand Down
7 changes: 7 additions & 0 deletions libs/model/src/models/groupPermission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { GroupPermission } from '@hicommonwealth/schemas';
import Sequelize from 'sequelize'; // must use "* as" to avoid scope errors
import { z } from 'zod';
import { GroupAttributes } from './group';
import { TopicAttributes } from './topic';
import type { ModelInstance } from './types';

export type GroupPermissionAttributes = z.infer<typeof GroupPermission> & {
// associations
Group?: GroupAttributes;
Topic?: TopicAttributes;
};

export type GroupPermissionInstance = ModelInstance<GroupPermissionAttributes>;
Expand All @@ -22,6 +24,11 @@ export default (
allowNull: false,
primaryKey: true,
},
topic_id: {
type: Sequelize.INTEGER,
allowNull: false,
primaryKey: true,
},
allowed_actions: {
// This needs to be a string[] because enum[] will break sequelize.sync and fail tests
type: Sequelize.ARRAY(Sequelize.STRING),
Expand Down
4 changes: 3 additions & 1 deletion libs/model/src/thread/CreateThread.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ export function CreateThread(): Command<
return {
...schemas.CreateThread,
auth: [
isAuthorized({ action: schemas.PermissionEnum.CREATE_THREAD }),
isAuthorized({
action: schemas.PermissionEnum.CREATE_THREAD
}),
verifyThreadSignature,
],
body: async ({ actor, payload, auth }) => {
Expand Down
25 changes: 22 additions & 3 deletions libs/model/test/community/community-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
dispose,
query,
} from '@hicommonwealth/core';
import { TopicWeightedVoting } from '@hicommonwealth/schemas';
import {
PermissionEnum,
TopicWeightedVoting,
} from '@hicommonwealth/schemas';
import { ChainBase, ChainType } from '@hicommonwealth/shared';
import { Chance } from 'chance';
import { CreateTopic } from 'model/src/community/CreateTopic.command';
Expand Down Expand Up @@ -38,7 +41,10 @@ import { seed } from '../../src/tester';

const chance = Chance();

function buildCreateGroupPayload(community_id: string, topics: number[] = []) {
function buildCreateGroupPayload(
community_id: string,
topics: { id: number; permissions: PermissionEnum[] }[] = [],
) {
return {
community_id,
metadata: {
Expand Down Expand Up @@ -355,7 +361,20 @@ describe('Community lifecycle', () => {
await expect(
command(CreateGroup(), {
actor: ethAdminActor,
payload: buildCreateGroupPayload(community.id, [1, 2, 3]),
payload: buildCreateGroupPayload(community.id, [
{
id: 1,
permissions: [PermissionEnum.CREATE_COMMENT, PermissionEnum.CREATE_THREAD, PermissionEnum.CREATE_COMMENT_REACTION,PermissionEnum.CREATE_THREAD_REACTION],
},
{
id: 2,
permissions: [PermissionEnum.CREATE_COMMENT, PermissionEnum.CREATE_THREAD, PermissionEnum.CREATE_COMMENT_REACTION,PermissionEnum.CREATE_THREAD_REACTION],
},
{
id: 3,
permissions: [PermissionEnum.CREATE_COMMENT, PermissionEnum.CREATE_THREAD, PermissionEnum.CREATE_COMMENT_REACTION,PermissionEnum.CREATE_THREAD_REACTION],
},
]),
}),
).rejects.toThrow(CreateGroupErrors.InvalidTopics);
});
Expand Down
2 changes: 2 additions & 0 deletions libs/model/test/thread/thread-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ describe('Thread lifecycle', () => {
});
await seed('GroupPermission', {
group_id: threadGroupId,
topic_id: _community?.topics?.[0]?.id || 0,
allowed_actions: [
schemas.PermissionEnum.CREATE_THREAD,
schemas.PermissionEnum.CREATE_THREAD_REACTION,
Expand All @@ -171,6 +172,7 @@ describe('Thread lifecycle', () => {
});
await seed('GroupPermission', {
group_id: commentGroupId,
topic_id: _community?.topics?.[0]?.id || 0,
allowed_actions: [schemas.PermissionEnum.CREATE_COMMENT],
});

Expand Down
19 changes: 17 additions & 2 deletions libs/schemas/src/commands/community.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { z } from 'zod';
import {
Community,
Group,
PermissionEnum,
Requirement,
StakeTransaction,
Topic,
Expand Down Expand Up @@ -228,7 +229,14 @@ export const CreateGroup = {
community_id: z.string(),
metadata: GroupMetadata,
requirements: z.array(Requirement).optional(),
topics: z.array(PG_INT).optional(),
topics: z
.array(
z.object({
id: PG_INT,
permissions: z.array(z.nativeEnum(PermissionEnum)),
}),
)
.optional(),
}),
output: Community.extend({ groups: z.array(Group).optional() }).partial(),
};
Expand All @@ -239,7 +247,14 @@ export const UpdateGroup = {
group_id: PG_INT,
metadata: GroupMetadata.optional(),
requirements: z.array(Requirement).optional(),
topics: z.array(PG_INT).optional(),
topics: z
.array(
z.object({
id: PG_INT,
permissions: z.array(z.nativeEnum(PermissionEnum)),
}),
)
.optional(),
}),
output: Group.partial(),
};
Expand Down
6 changes: 3 additions & 3 deletions libs/schemas/src/entities/group-permission.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ export enum PermissionEnum {
CREATE_COMMENT = 'CREATE_COMMENT',
CREATE_THREAD_REACTION = 'CREATE_THREAD_REACTION',
CREATE_COMMENT_REACTION = 'CREATE_COMMENT_REACTION',
UPDATE_POLL = 'UPDATE_POLL',
UPDATE_POLL = 'UPDATE_POLL'
}

export type GroupPermissionAction = keyof typeof PermissionEnum;

export const GroupPermission = z.object({
group_id: PG_INT.optional(),
group_id: PG_INT,
topic_id: PG_INT,
allowed_actions: z.array(z.nativeEnum(PermissionEnum)),

created_at: z.coerce.date().optional(),
updated_at: z.coerce.date().optional(),
});
10 changes: 10 additions & 0 deletions packages/commonwealth/client/scripts/helpers/threads.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { PermissionEnum } from '@hicommonwealth/schemas';
import { re_weburl } from 'lib/url-validation';
import { Link, LinkSource } from 'models/Thread';
// eslint-disable-next-line max-len
import { convertGranularPermissionsToAccumulatedPermissions } from '../views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers';

export function detectURL(str: string) {
if (str.slice(0, 4) !== 'http') str = `http://${str}`; // no https required because this is only used for regex match
Expand Down Expand Up @@ -55,17 +58,24 @@ export const getThreadActionTooltipText = ({
isThreadArchived = false,
isThreadLocked = false,
isThreadTopicGated = false,
threadTopicInteractionRestrictions,
}: {
isCommunityMember?: boolean;
isThreadArchived?: boolean;
isThreadLocked?: boolean;
isThreadTopicGated?: boolean;
threadTopicInteractionRestrictions?: PermissionEnum[];
}): GetThreadActionTooltipTextResponse => {
if (!isCommunityMember) {
return getActionTooltipForNonCommunityMember;
}
if (isThreadArchived) return 'Thread is archived';
if (isThreadLocked) return 'Thread is locked';
if (isThreadTopicGated) return 'Topic is gated';
if (threadTopicInteractionRestrictions) {
return `Topic members are only allowed to ${convertGranularPermissionsToAccumulatedPermissions(
threadTopicInteractionRestrictions,
)}`;
}
return '';
};
Loading
Loading