• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hicommonwealth / commonwealth / 12284359451

11 Dec 2024 08:28PM UTC coverage: 47.172% (+0.01%) from 47.159%
12284359451

Pull #10016

github

web-flow
Merge d127b8066 into 373f274d5
Pull Request #10016: Add full end to end integration tests for web3

1190 of 2833 branches covered (42.0%)

Branch coverage included in aggregate %.

2 of 7 new or added lines in 3 files covered. (28.57%)

3 existing lines in 1 file now uncovered.

2454 of 4892 relevant lines covered (50.16%)

31.47 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.08
/libs/model/src/middleware/auth.ts
1
import {
2
  Actor,
3
  Context,
4
  InvalidActor,
5
  InvalidInput,
6
  InvalidState,
7
} from '@hicommonwealth/core';
8
import {
9
  Address,
10
  AuthContext,
11
  AuthContextInput,
12
  CommentContext,
13
  CommentContextInput,
14
  Group,
15
  GroupPermissionAction,
16
  PollContext,
17
  PollContextInput,
18
  ReactionContext,
19
  ReactionContextInput,
20
  ThreadContext,
21
  ThreadContextInput,
22
  TopicContext,
23
  TopicContextInput,
24
} from '@hicommonwealth/schemas';
25
import { Role } from '@hicommonwealth/shared';
26
import { Op, QueryTypes } from 'sequelize';
27
import { ZodSchema, z } from 'zod';
28
import { models } from '../database';
29
import { AddressInstance } from '../models';
30
import { BannedActor, NonMember, RejectedMember } from './errors';
31

32
async function findComment(actor: Actor, comment_id: number) {
33
  const comment = await models.Comment.findOne({
15✔
34
    where: { id: comment_id },
35
    include: [
36
      {
37
        model: models.Thread,
38
        required: true,
39
      },
40
    ],
41
  });
42
  if (!comment)
15✔
43
    throw new InvalidInput('Must provide a valid comment id to authorize');
2✔
44

45
  return {
13✔
46
    comment_id,
47
    comment,
48
    author_address_id: comment.address_id,
49
    community_id: comment.Thread!.community_id!,
50
    topic_id: comment.Thread!.topic_id ?? undefined,
13!
51
    thread_id: comment.Thread!.id!,
52
  };
53
}
54

55
async function findThread(
56
  actor: Actor,
57
  thread_id: number,
58
  collaborators: boolean,
59
) {
60
  const include = collaborators
47✔
61
    ? {
62
        model: models.Address,
63
        as: 'collaborators',
64
        required: false,
65
      }
66
    : undefined;
67
  const thread = await models.Thread.findOne({
47✔
68
    where: { id: thread_id },
69
    include,
70
  });
71
  if (!thread)
47✔
72
    throw new InvalidInput('Must provide a valid thread id to authorize');
3✔
73

74
  let is_collaborator = false;
44✔
75
  if (collaborators) {
44✔
76
    const found = thread?.collaborators?.find(
13✔
77
      ({ address }) => address === actor.address,
19✔
78
    );
79
    is_collaborator = !!found;
13✔
80
  }
81

82
  return {
44✔
83
    thread_id,
84
    thread,
85
    author_address_id: thread.address_id,
86
    community_id: thread.community_id,
87
    topic_id: thread.topic_id,
88
    is_collaborator,
89
  };
90
}
91

92
async function findTopic(actor: Actor, topic_id: number) {
93
  const topic = await models.Topic.findOne({ where: { id: topic_id } });
23✔
94
  if (!topic)
23✔
95
    throw new InvalidInput('Must provide a valid topic id to authorize');
1✔
96

97
  return {
22✔
98
    topic_id,
99
    topic,
100
    community_id: topic.community_id,
101
  };
102
}
103

104
async function findReaction(
105
  actor: Actor,
106
  community_id: string,
107
  reaction_id: number,
108
) {
109
  const reaction = await models.Reaction.findOne({
4✔
110
    where: { id: reaction_id },
111
  });
112
  if (!reaction)
4✔
113
    throw new InvalidInput('Must provide a valid reaction id to authorize');
1✔
114

115
  return {
3✔
116
    reaction_id,
117
    reaction,
118
    community_id,
119
    author_address_id: reaction.address_id,
120
  };
121
}
122

123
async function findPoll(actor: Actor, poll_id: number) {
124
  const poll = await models.Poll.findOne({
×
125
    where: { id: poll_id },
126
  });
127
  if (!poll) {
×
128
    throw new InvalidInput('Must provide a valid poll id to authorize');
×
129
  }
130

131
  return poll;
×
132
}
133

134
async function findAddress(
135
  actor: Actor,
136
  community_id: string,
137
  roles: Role[],
138
  author_address_id?: number,
139
): Promise<{ address: AddressInstance; is_author: boolean }> {
140
  if (!actor.address)
163!
141
    throw new InvalidActor(actor, 'Must provide an address to authorize');
×
142

143
  // Policies as system actors behave like super admins
144
  // TODO: we can check if there is an address to load or fake it
145
  if (actor.is_system_actor) {
163✔
146
    return {
1✔
147
      address: {} as AddressInstance,
148
      is_author: false,
149
    };
150
  }
151

152
  if (!community_id)
162!
NEW
153
    throw new InvalidInput('Must provide a valid community id to authorize');
×
154

155
  // Loads and tracks real user's address activity
156
  const address = await models.Address.findOne({
162✔
157
    where: {
158
      user_id: actor.user.id,
159
      address: actor.address,
160
      community_id,
161
      role: { [Op.in]: roles },
162
      verified: { [Op.ne]: null },
163
      // TODO: check verification token expiration
164
    },
165
    order: [['role', 'DESC']],
166
  });
167

168
  if (address) {
162✔
169
    // fire and forget address activity tracking
170
    address.last_active = new Date();
142✔
171
    void address.save();
142✔
172
    return {
142✔
173
      address,
174
      is_author: address.id === author_address_id,
175
    };
176
  }
177

178
  // simulate non-member super admins
179
  if (!actor.user.isAdmin)
20✔
180
    throw new InvalidActor(actor, `User is not ${roles} in the community`);
11✔
181

182
  const super_address = await models.Address.findOne({
9✔
183
    where: {
184
      user_id: actor.user.id,
185
      address: actor.address,
186
    },
187
  });
188
  if (!super_address)
9!
189
    throw new InvalidActor(actor, `Super admin address not found`);
×
190

191
  return {
9✔
192
    address: super_address,
193
    is_author: false,
194
  };
195
}
196

197
/**
198
 * Checks if actor address passes a set of requirements and grants access for all groups of the given topic
199
 */
200
async function hasTopicPermissions(
201
  actor: Actor,
202
  address_id: number,
203
  action: GroupPermissionAction,
204
  topic_id: number,
205
): Promise<void> {
206
  if (!topic_id)
38!
207
    throw new InvalidInput('Must provide a valid topic id to authorize');
×
208

209
  const topic = await models.Topic.findOne({ where: { id: topic_id } });
38✔
210
  if (!topic) throw new InvalidInput('Topic not found');
38!
211

212
  if (topic.group_ids?.length === 0) return;
38✔
213

214
  // check if user has permission to perform "action" in 'topic_id'
215
  // the 'topic_id' can belong to any group where user has membership
216
  // the group with 'topic_id' having higher permissions will take precedence
217
  const groups = await models.sequelize.query<
33✔
218
    z.infer<typeof Group> & {
219
      allowed_actions?: GroupPermissionAction[];
220
    }
221
  >(
222
    `
223
        SELECT g.*, gp.topic_id, gp.allowed_actions
224
        FROM "Groups" as g
225
                 JOIN "GroupPermissions" gp ON g.id = gp.group_id
226
        WHERE g.community_id = :community_id
227
          AND gp.topic_id = :topic_id
228
    `,
229
    {
230
      type: QueryTypes.SELECT,
231
      raw: true,
232
      replacements: {
233
        community_id: topic.community_id,
234
        topic_id: topic.id,
235
      },
236
    },
237
  );
238

239
  // There are 2 cases here. We either have the old group permission system where the group doesn't have
240
  // any group_allowed_actions, or we have the new fine-grained permission system where the action must be in
241
  // the group_allowed_actions list.
242
  const allowedActions = groups.filter(
33✔
243
    (g) => !g.allowed_actions || g.allowed_actions.includes(action),
65✔
244
  );
245
  if (allowedActions.length === 0)
33✔
246
    throw new NonMember(actor, topic.name, action);
1✔
247

248
  // check membership for all groups of topic
249
  const memberships = await models.Membership.findAll({
32✔
250
    where: {
251
      group_id: { [Op.in]: allowedActions.map((g) => g.id!) },
32✔
252
      address_id,
253
    },
254
    include: [
255
      {
256
        model: models.Group,
257
        as: 'group',
258
      },
259
    ],
260
  });
261
  if (memberships.length === 0) throw new NonMember(actor, topic.name, action);
32✔
262

263
  const rejects = memberships.filter((m) => m.reject_reason);
27✔
264
  if (rejects.length === memberships.length)
27✔
265
    throw new RejectedMember(
2✔
266
      actor,
267
      rejects.flatMap((reject) =>
268
        reject.reject_reason!.map((reason) => reason.message),
2✔
269
      ),
270
    );
271
}
272

273
/**
274
 * Generic authorization guard used by all middleware once the authorization context is loaded
275
 */
276
async function mustBeAuthorized(
277
  { actor, context }: Context<ZodSchema, ZodSchema>,
278
  check: {
279
    permissions?: {
280
      topic_id: number;
281
      action: GroupPermissionAction;
282
    };
283
    author?: boolean;
284
    collaborators?: z.infer<typeof Address>[];
285
  },
286
) {
287
  // System actors are always allowed
288
  if (actor.is_system_actor) return;
152✔
289

290
  // Admins (and super admins) are always allowed to act on any entity
291
  if (actor.user.isAdmin || context.address.role === 'admin') return;
151✔
292

293
  // Banned actors are always rejected (if not admin or system actors)
294
  if (context.address.is_banned) throw new BannedActor(actor);
56✔
295

296
  // Author is always allowed to act on their own entity, unless banned
297
  if (context.is_author) return;
54✔
298

299
  // Allows when actor has group permissions in topic
300
  if (check.permissions)
46✔
301
    return await hasTopicPermissions(
38✔
302
      actor,
303
      context.address!.id!,
304
      check.permissions.action,
305
      check.permissions.topic_id,
306
    );
307

308
  // Allows when actor is a collaborator in the thread
309
  if (check.collaborators) {
8✔
310
    const found = check.collaborators?.find(
5✔
311
      ({ address }) => address === actor.address,
8✔
312
    );
313
    context.is_collaborator = !!found;
5✔
314
    if (context.is_collaborator) return;
5✔
315
    throw new InvalidActor(actor, 'Not authorized collaborator');
1✔
316
  }
317

318
  // At this point, we know the actor is not the author of the entity
319
  // and it's also not an admin, system actor, or collaborator...
320
  // This guard is used to enforce that the author is the only one who can
321
  // perform actions on the entity.
322
  if (check.author)
3!
323
    throw new InvalidActor(actor, 'Not the author of the entity');
3✔
324
}
325

326
/**
327
 * Utility to easily create a system actor.
328
 * We can identify each policy actor by a predefined system user id,
329
 * email, and address.
330
 * This will allow us to audit and track distinct policy actors.
331
 *
332
 * @param address a distict policy address, defaults to "0x0"
333
 * @param id a distict policy user id, defaults to 0
334
 * @param email a distict policy email address, defaults to `system@common.im`
335
 * @returns system actor flagged as a system actor
336
 */
337
export const systemActor = ({
26✔
338
  address = '0x0',
1✔
339
  id = 0,
1✔
340
  email = 'system@common.im',
1✔
341
}: {
342
  address?: string;
343
  id?: number;
344
  email?: string;
345
}): Actor => ({
1✔
346
  user: { id, email },
347
  address,
348
  is_system_actor: true,
349
});
350

351
export async function isSuperAdmin(ctx: Context<ZodSchema, ZodSchema>) {
352
  if (!ctx.actor.user.isAdmin)
×
353
    await Promise.reject(new InvalidActor(ctx.actor, 'Must be a super admin'));
×
354
}
355

356
/**
357
 * Validates if actor's address is authorized
358
 * @param roles specific community roles - all by default
359
 * @throws InvalidActor when not authorized
360
 */
361
export function authRoles(...roles: Role[]) {
362
  return async (ctx: Context<typeof AuthContextInput, typeof AuthContext>) => {
82✔
363
    const { address, is_author } = await findAddress(
81✔
364
      ctx.actor,
365
      ctx.payload.community_id,
366
      ctx.actor.user.isAdmin || !roles.length
235✔
367
        ? ['admin', 'moderator', 'member']
368
        : roles,
369
    );
370

371
    (ctx as { context: AuthContext }).context = {
72✔
372
      address,
373
      is_author,
374
      community_id: ctx.payload.community_id,
375
    };
376

377
    await mustBeAuthorized(ctx, {});
72✔
378
  };
379
}
380

381
type AggregateAuthOptions = {
382
  roles?: Role[];
383
  action?: GroupPermissionAction;
384
  author?: boolean;
385
  collaborators?: boolean;
386
};
387

388
/**
389
 * Validates if actor's address is authorized to perform actions on a comment
390
 * @param action specific group permission action
391
 * @param author when true, rejects members that are not the author
392
 * @throws InvalidActor when not authorized
393
 */
394
export function authComment({ action, author }: AggregateAuthOptions) {
395
  return async (
15✔
396
    ctx: Context<typeof CommentContextInput, typeof CommentContext>,
397
  ) => {
398
    const auth = await findComment(ctx.actor, ctx.payload.comment_id);
15✔
399
    const { address, is_author } = await findAddress(
13✔
400
      ctx.actor,
401
      auth.community_id,
402
      ['admin', 'moderator', 'member'],
403
      auth.author_address_id,
404
    );
405

406
    (ctx as { context: CommentContext }).context = {
13✔
407
      ...auth,
408
      address,
409
      is_author,
410
    };
411

412
    await mustBeAuthorized(ctx, {
13✔
413
      permissions: action ? { action, topic_id: auth.topic_id! } : undefined,
13✔
414
      author,
415
    });
416
  };
417
}
418

419
/**
420
 * Validates if actor's address is authorized to perform actions on a thread
421
 * @param action specific group permission action
422
 * @param author when true, rejects members that are not the author
423
 * @param collaborators authorize thread collaborators
424
 * @throws InvalidActor when not authorized
425
 */
426
export function authThread({
427
  action,
428
  author,
429
  collaborators,
430
}: AggregateAuthOptions) {
431
  return async (
47✔
432
    ctx: Context<typeof ThreadContextInput, typeof ThreadContext>,
433
  ) => {
434
    const auth = await findThread(
47✔
435
      ctx.actor,
436
      ctx.payload.thread_id,
437
      collaborators ?? false,
81✔
438
    );
439
    const { address, is_author } = await findAddress(
44✔
440
      ctx.actor,
441
      auth.community_id,
442
      ['admin', 'moderator', 'member'],
443
      auth.author_address_id,
444
    );
445

446
    (ctx as { context: ThreadContext }).context = {
44✔
447
      ...auth,
448
      address,
449
      is_author,
450
    };
451

452
    await mustBeAuthorized(ctx, {
44✔
453
      permissions: action ? { action, topic_id: auth.topic_id! } : undefined,
44✔
454
      author,
455
      collaborators: collaborators ? auth.thread.collaborators! : undefined,
44✔
456
    });
457
  };
458
}
459

460
/**
461
 * Validates if actor's address is authorized to perform actions on a topic
462
 * @param action specific group permission action
463
 * @throws InvalidActor when not authorized
464
 */
465
export function authTopic({ roles, action }: AggregateAuthOptions) {
466
  return async (
23✔
467
    ctx: Context<typeof TopicContextInput, typeof TopicContext>,
468
  ) => {
469
    const auth = await findTopic(ctx.actor, ctx.payload.topic_id);
23✔
470
    const { address, is_author } = await findAddress(
22✔
471
      ctx.actor,
472
      auth.community_id,
473
      roles ?? ['admin', 'moderator', 'member'],
39✔
474
    );
475

476
    (ctx as { context: TopicContext }).context = {
20✔
477
      ...auth,
478
      address,
479
      is_author,
480
    };
481

482
    await mustBeAuthorized(ctx, {
20✔
483
      permissions: action ? { action, topic_id: auth.topic_id } : undefined,
20✔
484
    });
485
  };
486
}
487

488
/**
489
 * Validates if actor's address is authorized to perform actions on a reaction
490
 * @throws InvalidActor when not authorized
491
 */
492
export function authReaction() {
493
  return async (
4✔
494
    ctx: Context<typeof ReactionContextInput, typeof ReactionContext>,
495
  ) => {
496
    const auth = await findReaction(
4✔
497
      ctx.actor,
498
      ctx.payload.community_id,
499
      ctx.payload.reaction_id,
500
    );
501
    const { address, is_author } = await findAddress(
3✔
502
      ctx.actor,
503
      auth.community_id,
504
      ['admin', 'moderator', 'member'],
505
      auth.author_address_id,
506
    );
507

508
    (ctx as { context: ReactionContext }).context = {
3✔
509
      ...auth,
510
      address,
511
      is_author,
512
    };
513

514
    // reactions are only authorized by the author
515
    await mustBeAuthorized(ctx, { author: true });
3✔
516
  };
517
}
518

519
export function authPoll({ action }: AggregateAuthOptions) {
520
  return async (ctx: Context<typeof PollContextInput, typeof PollContext>) => {
×
521
    const poll = await findPoll(ctx.actor, ctx.payload.poll_id);
×
522
    const threadAuth = await findThread(ctx.actor, poll.thread_id, false);
×
523
    const { address, is_author } = await findAddress(
×
524
      ctx.actor,
525
      threadAuth.community_id,
526
      ['admin', 'moderator', 'member'],
527
    );
528

529
    if (threadAuth.thread.archived_at)
×
530
      throw new InvalidState('Thread is archived');
×
531
    (ctx as { context: PollContext }).context = {
×
532
      address,
533
      is_author,
534
      poll,
535
      poll_id: poll.id!,
536
      community_id: threadAuth.community_id,
537
      thread: threadAuth.thread,
538
    };
539

540
    await mustBeAuthorized(ctx, {
×
541
      author: true,
542
      permissions: action
×
543
        ? {
544
            topic_id: threadAuth.topic_id,
545
            action,
546
          }
547
        : undefined,
548
    });
549
  };
550
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc