@meith/authorization 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,569 @@
1
+ import {
2
+ authorFilterFrom,
3
+ type ContentScope,
4
+ contentScopeFrom,
5
+ ForbiddenError,
6
+ type ForumPermissions,
7
+ NO_THREAD_AUDIENCE,
8
+ type ThreadAudience,
9
+ type ThreadAuthorFilter,
10
+ unrestrictedAudience,
11
+ } from '@meith/core'
12
+
13
+ import { indexOverrides, resolveForumMatrix } from './resolve'
14
+ import type {
15
+ Action,
16
+ Actor,
17
+ AuthorizationSource,
18
+ ModeratedTarget,
19
+ NumericGlobalPermission,
20
+ Target,
21
+ Visible,
22
+ } from './types'
23
+ import { hasAnyModeratorRight, type ModeratorRights, NO_MODERATOR_RIGHTS } from './types'
24
+
25
+ export interface BypassEvent {
26
+ readonly kind: 'administrator' | 'super_moderator'
27
+ readonly userId: number | null
28
+ readonly action: Action
29
+ readonly forumId: number | undefined
30
+ }
31
+
32
+ export interface AuthorizerOptions {
33
+ onBypass?: (event: BypassEvent) => void
34
+ }
35
+
36
+ const FORUM_SCOPED: ReadonlySet<Action> = new Set<Action>([
37
+ 'forum.view',
38
+ 'thread.view',
39
+ 'thread.viewOthers',
40
+ 'thread.post',
41
+ 'reply.post',
42
+ 'poll.post',
43
+ 'poll.vote',
44
+ 'thread.rate',
45
+ 'post.editOwn',
46
+ 'post.editOthers',
47
+ 'post.deleteOwn',
48
+ 'post.softDelete',
49
+ 'post.restore',
50
+ 'content.viewUnapproved',
51
+ 'content.viewDeleted',
52
+ 'content.approve',
53
+ 'thread.lock',
54
+ 'thread.stick',
55
+ 'thread.move',
56
+ 'thread.delete',
57
+ 'thread.restore',
58
+ 'thread.merge',
59
+ 'thread.split',
60
+ 'attachment.upload',
61
+ 'attachment.download',
62
+ 'forum.search',
63
+ 'forum.subscribe',
64
+ ])
65
+
66
+ const ADMIN_ALWAYS: ReadonlySet<Action> = new Set<Action>([
67
+ ...FORUM_SCOPED,
68
+ 'modcp.access',
69
+ 'profile.view',
70
+ 'memberlist.view',
71
+ 'pm.use',
72
+ 'content.report',
73
+ 'reputation.give',
74
+ 'signature.use',
75
+ 'flood.bypass',
76
+ 'board.viewOffline',
77
+ ])
78
+
79
+ export class Authorizer {
80
+ constructor(
81
+ private readonly source: AuthorizationSource,
82
+ private readonly options: AuthorizerOptions = {},
83
+ ) {}
84
+
85
+ can(actor: Actor, action: Action, target: Target = {}): boolean {
86
+ if (actor.state === 'banned') return false
87
+ if (actor.state === 'awaiting_activation' && !isReadAction(action)) {
88
+ return false
89
+ }
90
+
91
+ if (actor.global.isAdministrator === true && ADMIN_ALWAYS.has(action)) {
92
+ this.logBypass('administrator', actor, action, target.forumId)
93
+ return true
94
+ }
95
+
96
+ if (actor.global.isSuperModerator === true && FORUM_SCOPED.has(action)) {
97
+ this.logBypass('super_moderator', actor, action, target.forumId)
98
+ return true
99
+ }
100
+
101
+ return FORUM_SCOPED.has(action)
102
+ ? this.canForum(actor, action, target)
103
+ : this.canGlobal(actor, action)
104
+ }
105
+
106
+ require(actor: Actor, action: Action, target: Target = {}): void {
107
+ if (!this.can(actor, action, target)) {
108
+ throw new ForbiddenError(`Not permitted: ${action}`, {
109
+ meta: { userId: actor.userId, action, forumId: target.forumId },
110
+ })
111
+ }
112
+ }
113
+
114
+ inAnyGroup(actor: Actor, groupIds: readonly number[]): boolean {
115
+ if (groupIds.length === 0) return true
116
+ return actor.groupIds.some((id) => groupIds.includes(id))
117
+ }
118
+
119
+ async forumMatrix(actor: Actor, forumId: number): Promise<ForumPermissions> {
120
+ const chain = await this.source.ancestorChain(forumId)
121
+ const groups = await this.source.groupDefaults(actor.groupIds)
122
+ const overrides = await this.source.forumOverrides(chain, actor.groupIds)
123
+ return resolveForumMatrix(chain, groups, indexOverrides(overrides))
124
+ }
125
+
126
+ async visibleForumIds(actor: Actor): Promise<number[]> {
127
+ if (actor.global.isAdministrator === true) {
128
+ return [...(await this.source.allForumIds())]
129
+ }
130
+
131
+ const chains = await this.source.allAncestorChains()
132
+ const groups = await this.source.groupDefaults(actor.groupIds)
133
+
134
+ const everyForumInvolved = [...new Set([...chains.values()].flat())]
135
+ const overrides = indexOverrides(
136
+ await this.source.forumOverrides(everyForumInvolved, actor.groupIds),
137
+ )
138
+
139
+ const visible: number[] = []
140
+ for (const [forumId, chain] of chains) {
141
+ const matrix = resolveForumMatrix(chain, groups, overrides)
142
+ if (matrix.canView === true) visible.push(forumId)
143
+ }
144
+ return visible
145
+ }
146
+
147
+ async listingVisibility(actor: Actor): Promise<{
148
+ readonly visibleForumIds: number[]
149
+ readonly ownThreadsOnlyForumIds: number[]
150
+ }> {
151
+ if (actor.global.isAdministrator === true) {
152
+ return {
153
+ visibleForumIds: [...(await this.source.allForumIds())],
154
+ ownThreadsOnlyForumIds: [],
155
+ }
156
+ }
157
+
158
+ const visibleForumIds: number[] = []
159
+ const ownThreadsOnlyForumIds: number[] = []
160
+ for (const target of await this.resolvedTargets(actor)) {
161
+ visibleForumIds.push(target.forumId)
162
+ if (this.can(actor, 'thread.view', target) && !this.can(actor, 'thread.viewOthers', target)) {
163
+ ownThreadsOnlyForumIds.push(target.forumId)
164
+ }
165
+ }
166
+ return { visibleForumIds, ownThreadsOnlyForumIds }
167
+ }
168
+
169
+ async moderatedForumIds(
170
+ actor: Actor,
171
+ right: keyof ModeratorRights = 'canApproveContent',
172
+ ): Promise<number[]> {
173
+ if (actor.global.isAdministrator === true || actor.global.isSuperModerator === true) {
174
+ return [...(await this.source.allForumIds())]
175
+ }
176
+
177
+ const [chains, groups, appointments] = await Promise.all([
178
+ this.source.allAncestorChains(),
179
+ this.source.groupDefaults(actor.groupIds),
180
+ this.source.moderatorAppointments(actor.userId, actor.groupIds),
181
+ ])
182
+
183
+ const everyForumInvolved = [...new Set([...chains.values()].flat())]
184
+ const overrides = indexOverrides(
185
+ await this.source.forumOverrides(everyForumInvolved, actor.groupIds),
186
+ )
187
+
188
+ const approvesByAppointment = new Set<number>()
189
+ for (const [forumId, chain] of chains) {
190
+ for (const appointment of appointments) {
191
+ if (!appointment[right]) continue
192
+ if (appointment.forumId === forumId) {
193
+ approvesByAppointment.add(forumId)
194
+ } else if (appointment.cascadeToSubforums && chain.includes(appointment.forumId)) {
195
+ approvesByAppointment.add(forumId)
196
+ }
197
+ }
198
+ }
199
+
200
+ const moderated: number[] = []
201
+ for (const [forumId, chain] of chains) {
202
+ const matrix = resolveForumMatrix(chain, groups, overrides)
203
+ if (matrix.canView !== true) continue
204
+ const byGroup = right === 'canApproveContent' && matrix.canApproveContent === true
205
+ if (byGroup || approvesByAppointment.has(forumId)) moderated.push(forumId)
206
+ }
207
+ return moderated
208
+ }
209
+
210
+ applicableGroupRows<T extends { readonly groupId: number }>(
211
+ actor: Actor,
212
+ rows: readonly T[],
213
+ ): readonly T[] {
214
+ return this.applicableGroupRowsForGroups(actor.groupIds, rows)
215
+ }
216
+
217
+ applicableGroupRowsForGroups<T extends { readonly groupId: number }>(
218
+ groupIds: readonly number[],
219
+ rows: readonly T[],
220
+ ): readonly T[] {
221
+ const mine = new Set(groupIds)
222
+ return rows.filter((row) => mine.has(row.groupId))
223
+ }
224
+
225
+ async moderatorRightsIn(actor: Actor, forumId: number): Promise<ModeratorRights> {
226
+ if (actor.global.isAdministrator === true || actor.global.isSuperModerator === true) {
227
+ return ALL_MODERATOR_RIGHTS
228
+ }
229
+ if (actor.userId === null) return NO_MODERATOR_RIGHTS
230
+
231
+ const [chain, appointments] = await Promise.all([
232
+ this.source.ancestorChain(forumId),
233
+ this.source.moderatorAppointments(actor.userId, actor.groupIds),
234
+ ])
235
+ if (chain.length === 0) return NO_MODERATOR_RIGHTS
236
+
237
+ let rights = NO_MODERATOR_RIGHTS
238
+ for (const appointment of appointments) {
239
+ const covers =
240
+ appointment.forumId === forumId ||
241
+ (appointment.cascadeToSubforums && chain.includes(appointment.forumId))
242
+ if (covers) rights = unionRights(rights, appointment)
243
+ }
244
+ return rights
245
+ }
246
+
247
+ async forumIdsWhere(actor: Actor, action: Action): Promise<number[]> {
248
+ if (!FORUM_SCOPED.has(action)) {
249
+ throw new Error(`forumIdsWhere is only meaningful for forum-scoped actions: ${action}`)
250
+ }
251
+ if (actor.state === 'banned') return []
252
+
253
+ if (actor.global.isAdministrator === true && ADMIN_ALWAYS.has(action)) {
254
+ this.logBypass('administrator', actor, action, undefined)
255
+ return [...(await this.source.allForumIds())]
256
+ }
257
+ if (actor.global.isSuperModerator === true) {
258
+ this.logBypass('super_moderator', actor, action, undefined)
259
+ return [...(await this.source.allForumIds())]
260
+ }
261
+
262
+ const permitted: number[] = []
263
+ for (const target of await this.resolvedTargets(actor)) {
264
+ if (this.can(actor, action, target)) permitted.push(target.forumId)
265
+ }
266
+ return permitted
267
+ }
268
+
269
+ async threadAudience(actor: Actor): Promise<ThreadAudience> {
270
+ if (actor.state === 'banned') {
271
+ return { ...NO_THREAD_AUDIENCE, viewerUserId: actor.userId }
272
+ }
273
+
274
+ if (actor.global.isAdministrator === true) {
275
+ this.logBypass('administrator', actor, 'thread.view', undefined)
276
+ return unrestrictedAudience([...(await this.source.allForumIds())], actor.userId)
277
+ }
278
+ if (actor.global.isSuperModerator === true) {
279
+ this.logBypass('super_moderator', actor, 'thread.view', undefined)
280
+ return unrestrictedAudience([...(await this.source.allForumIds())], actor.userId)
281
+ }
282
+
283
+ const forumIds: number[] = []
284
+ const ownThreadsOnlyForumIds: number[] = []
285
+ for (const target of await this.resolvedTargets(actor)) {
286
+ if (!this.can(actor, 'thread.view', target)) continue
287
+ forumIds.push(target.forumId)
288
+ if (!this.can(actor, 'thread.viewOthers', target)) {
289
+ ownThreadsOnlyForumIds.push(target.forumId)
290
+ }
291
+ }
292
+ return { forumIds, ownThreadsOnlyForumIds, viewerUserId: actor.userId }
293
+ }
294
+
295
+ authorFilter(actor: Actor, target: Target): ThreadAuthorFilter {
296
+ return authorFilterFrom({
297
+ seesOthersThreads: this.can(actor, 'thread.viewOthers', target),
298
+ viewerUserId: actor.userId,
299
+ })
300
+ }
301
+
302
+ async authorFilterIn(
303
+ actor: Actor,
304
+ forumId: number,
305
+ forum: ForumPermissions,
306
+ ): Promise<ThreadAuthorFilter> {
307
+ return this.authorFilter(actor, await this.moderatorTargetIn(actor, forumId, forum))
308
+ }
309
+
310
+ private async resolvedTargets(actor: Actor): Promise<readonly ModeratedTarget[]> {
311
+ const [chains, groups, appointments] = await Promise.all([
312
+ this.source.allAncestorChains(),
313
+ this.source.groupDefaults(actor.groupIds),
314
+ this.source.moderatorAppointments(actor.userId, actor.groupIds),
315
+ ])
316
+
317
+ const everyForumInvolved = [...new Set([...chains.values()].flat())]
318
+ const overrides = indexOverrides(
319
+ await this.source.forumOverrides(everyForumInvolved, actor.groupIds),
320
+ )
321
+
322
+ const targets: ModeratedTarget[] = []
323
+ for (const [forumId, chain] of chains) {
324
+ const forum = resolveForumMatrix(chain, groups, overrides)
325
+ if (forum.canView !== true) continue
326
+
327
+ let moderatorRights = NO_MODERATOR_RIGHTS
328
+ let appointed = false
329
+ for (const appointment of appointments) {
330
+ const covers =
331
+ appointment.forumId === forumId ||
332
+ (appointment.cascadeToSubforums && chain.includes(appointment.forumId))
333
+ if (!covers) continue
334
+ appointed = true
335
+ moderatorRights = unionRights(moderatorRights, appointment)
336
+ }
337
+
338
+ targets.push({
339
+ forumId,
340
+ forum,
341
+ moderatorRights,
342
+ isForumModerator: appointed,
343
+ })
344
+ }
345
+ return targets
346
+ }
347
+
348
+ contentScope(actor: Actor, target: Target): ContentScope {
349
+ return contentScopeFrom({
350
+ seesUnapproved: this.can(actor, 'content.viewUnapproved', target),
351
+ seesDeleted: this.can(actor, 'content.viewDeleted', target),
352
+ })
353
+ }
354
+
355
+ async moderatorTargetIn(
356
+ actor: Actor,
357
+ forumId: number,
358
+ forum: ForumPermissions,
359
+ ): Promise<ModeratedTarget> {
360
+ const moderatorRights = await this.moderatorRightsIn(actor, forumId)
361
+ return {
362
+ forumId,
363
+ forum,
364
+ moderatorRights,
365
+ isForumModerator: hasAnyModeratorRight(moderatorRights),
366
+ }
367
+ }
368
+
369
+ async contentScopeIn(
370
+ actor: Actor,
371
+ forumId: number,
372
+ forum: ForumPermissions,
373
+ ): Promise<ContentScope> {
374
+ return this.contentScope(actor, await this.moderatorTargetIn(actor, forumId, forum))
375
+ }
376
+
377
+ globalLimit(actor: Actor, key: NumericGlobalPermission): number {
378
+ const value = actor.global[key]
379
+ return typeof value === 'number' ? value : 0
380
+ }
381
+
382
+ filterVisible<T extends Visible>(
383
+ _actor: Actor,
384
+ visibleForumIds: ReadonlySet<number>,
385
+ rows: readonly T[],
386
+ ): T[] {
387
+ return rows.filter((r) => visibleForumIds.has(r.forumId))
388
+ }
389
+
390
+ private canForum(actor: Actor, action: Action, target: Target): boolean {
391
+ const forum = target.forum
392
+ if (!forum) {
393
+ throw new Error(
394
+ `Forum-scoped action "${action}" requires target.forum (resolved matrix). ` +
395
+ `Call authorizer.forumMatrix() first.`,
396
+ )
397
+ }
398
+
399
+ if (forum.canView !== true) return false
400
+
401
+ if (
402
+ target.passwordRequired === true &&
403
+ target.passwordSatisfied !== true &&
404
+ action !== 'forum.view'
405
+ ) {
406
+ return false
407
+ }
408
+
409
+ const ownsContent = target.ownerId != null && target.ownerId === actor.userId
410
+ const ownsThread = target.threadAuthorId != null && target.threadAuthorId === actor.userId
411
+
412
+ switch (action) {
413
+ case 'forum.view':
414
+ return true
415
+ case 'thread.view': {
416
+ if (forum.canViewThreads !== true) return false
417
+ const author = target.threadAuthorId
418
+ if (author === undefined) return true
419
+ if (author !== null && author === actor.userId) return true
420
+ return this.canForum(actor, 'thread.viewOthers', target)
421
+ }
422
+ case 'thread.viewOthers':
423
+ return (
424
+ forum.canViewThreads === true &&
425
+ (target.isForumModerator === true || forum.canViewOthersThreads === true)
426
+ )
427
+ case 'forum.search':
428
+ return forum.canSearch === true
429
+ case 'forum.subscribe':
430
+ return forum.canSubscribe === true
431
+ case 'thread.post':
432
+ return forum.canPostThreads === true
433
+ case 'reply.post':
434
+ return forum.canPostReplies === true
435
+ case 'poll.post':
436
+ return forum.canPostPolls === true
437
+ case 'poll.vote':
438
+ return forum.canVotePolls === true
439
+ case 'thread.rate':
440
+ return forum.canRateThreads === true
441
+ case 'attachment.upload':
442
+ return forum.canUploadAttachments === true
443
+ case 'attachment.download':
444
+ return forum.canDownloadAttachments === true
445
+ case 'post.editOwn':
446
+ return ownsContent && forum.canEditOwnPosts === true
447
+ case 'post.deleteOwn':
448
+ return ownsContent && forum.canDeleteOwnPosts === true
449
+ case 'post.editOthers':
450
+ return (
451
+ (target.moderatorRights?.canEditPosts === true || forum.canEditOthersPosts === true) &&
452
+ !ownsContent
453
+ )
454
+ case 'post.softDelete':
455
+ return (
456
+ target.moderatorRights?.canSoftDeletePosts === true || forum.canSoftDeletePosts === true
457
+ )
458
+ case 'post.restore':
459
+ return target.moderatorRights?.canRestorePosts === true || forum.canSoftDeletePosts === true
460
+ case 'content.viewUnapproved':
461
+ return target.isForumModerator === true || forum.canViewUnapproved === true
462
+ case 'content.viewDeleted':
463
+ return target.isForumModerator === true || forum.canViewDeleted === true
464
+ case 'content.approve':
465
+ return (
466
+ target.moderatorRights?.canApproveContent === true || forum.canApproveContent === true
467
+ )
468
+
469
+ case 'thread.lock':
470
+ return target.moderatorRights?.canOpenCloseThreads === true
471
+ case 'thread.stick':
472
+ return target.moderatorRights?.canStickThreads === true
473
+ case 'thread.move':
474
+ return target.moderatorRights?.canMoveThreads === true
475
+ case 'thread.delete':
476
+ return (
477
+ target.moderatorRights?.canSoftDeletePosts === true ||
478
+ (ownsThread && forum.canDeleteOwnThreads === true)
479
+ )
480
+ case 'thread.restore':
481
+ return target.moderatorRights?.canRestorePosts === true
482
+ case 'thread.merge':
483
+ return target.moderatorRights?.canMergeThreads === true
484
+ case 'thread.split':
485
+ return target.moderatorRights?.canSplitThreads === true
486
+ default: {
487
+ const _exhaustive: never = action as never
488
+ return Boolean(_exhaustive)
489
+ }
490
+ }
491
+ }
492
+
493
+ private canGlobal(actor: Actor, action: Action): boolean {
494
+ switch (action) {
495
+ case 'profile.view':
496
+ return actor.global.canViewProfiles === true
497
+ case 'memberlist.view':
498
+ return actor.global.canViewMemberList === true
499
+ case 'pm.use':
500
+ return actor.global.canUsePrivateMessages === true
501
+ case 'avatar.upload':
502
+ return actor.global.canUploadAvatar === true
503
+ case 'content.report':
504
+ return actor.global.canReportContent === true
505
+ case 'user.warn':
506
+ return actor.global.canWarnUsers === true
507
+ case 'reputation.give':
508
+ return actor.global.canGiveReputation === true
509
+ case 'signature.use':
510
+ return actor.global.canUseSignature === true
511
+ case 'modcp.access':
512
+ return actor.global.canAccessModCp === true
513
+ case 'admincp.access':
514
+ return actor.global.canAccessAdminCp === true
515
+ case 'flood.bypass':
516
+ return actor.global.canBypassFloodCheck === true
517
+ case 'board.viewOffline':
518
+ return actor.global.canViewBoardOffline === true
519
+ default:
520
+ return false
521
+ }
522
+ }
523
+
524
+ private logBypass(
525
+ kind: BypassEvent['kind'],
526
+ actor: Actor,
527
+ action: Action,
528
+ forumId: number | undefined,
529
+ ): void {
530
+ this.options.onBypass?.({ kind, userId: actor.userId, action, forumId })
531
+ }
532
+ }
533
+
534
+ function isReadAction(action: Action): boolean {
535
+ return (
536
+ action === 'forum.view' ||
537
+ action === 'thread.view' ||
538
+ action === 'thread.viewOthers' ||
539
+ action === 'forum.search' ||
540
+ action === 'profile.view' ||
541
+ action === 'memberlist.view'
542
+ )
543
+ }
544
+
545
+ const ALL_MODERATOR_RIGHTS: ModeratorRights = {
546
+ canApproveContent: true,
547
+ canEditPosts: true,
548
+ canSoftDeletePosts: true,
549
+ canRestorePosts: true,
550
+ canOpenCloseThreads: true,
551
+ canStickThreads: true,
552
+ canMoveThreads: true,
553
+ canMergeThreads: true,
554
+ canSplitThreads: true,
555
+ }
556
+
557
+ function unionRights(a: ModeratorRights, b: ModeratorRights): ModeratorRights {
558
+ return {
559
+ canApproveContent: a.canApproveContent || b.canApproveContent,
560
+ canEditPosts: a.canEditPosts || b.canEditPosts,
561
+ canSoftDeletePosts: a.canSoftDeletePosts || b.canSoftDeletePosts,
562
+ canRestorePosts: a.canRestorePosts || b.canRestorePosts,
563
+ canOpenCloseThreads: a.canOpenCloseThreads || b.canOpenCloseThreads,
564
+ canStickThreads: a.canStickThreads || b.canStickThreads,
565
+ canMoveThreads: a.canMoveThreads || b.canMoveThreads,
566
+ canMergeThreads: a.canMergeThreads || b.canMergeThreads,
567
+ canSplitThreads: a.canSplitThreads || b.canSplitThreads,
568
+ }
569
+ }
package/src/combine.ts ADDED
@@ -0,0 +1,43 @@
1
+ import {
2
+ emptyPermissionSet,
3
+ PERMISSION_FIELDS,
4
+ type PermissionField,
5
+ type PermissionSet,
6
+ } from '@meith/core'
7
+
8
+ export function combineGroupValue(
9
+ kind: PermissionField['kind'],
10
+ values: readonly (boolean | number)[],
11
+ ): boolean | number {
12
+ switch (kind) {
13
+ case 'boolean': {
14
+ return values.some((v) => v === true)
15
+ }
16
+ case 'numeric': {
17
+ const nums = values.map(Number)
18
+ if (nums.some((n) => n === 0)) return 0
19
+ return nums.reduce((a, b) => Math.max(a, b), 0)
20
+ }
21
+ case 'negative': {
22
+ return values.every((v) => v === true)
23
+ }
24
+ default: {
25
+ const _exhaustive: never = kind
26
+ throw new Error(`Unhandled permission kind: ${String(_exhaustive)}`)
27
+ }
28
+ }
29
+ }
30
+
31
+ export function combinePermissionSets(sets: readonly PermissionSet[]): PermissionSet {
32
+ if (sets.length === 0) return emptyPermissionSet()
33
+ if (sets.length === 1) return { ...sets[0]! }
34
+
35
+ const out = emptyPermissionSet() as Record<string, boolean | number>
36
+
37
+ for (const field of PERMISSION_FIELDS) {
38
+ const values = sets.map((s) => (s as Record<string, boolean | number>)[field.key]!)
39
+ out[field.key] = combineGroupValue(field.kind, values)
40
+ }
41
+
42
+ return out as PermissionSet
43
+ }