From 011e73ec05b0db34f7c1d12382ec46e910e2d4dd Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Fri, 22 Nov 2024 15:28:53 +0000 Subject: [PATCH] :sparkles: Add protected tag setting (#3050) * :sparkles: Add protected tag setting * :white_check_mark: Add tests for protected tag options * :sparkles: Validate mod and role list * :broom: Replace usage of objects with Map * :bug: Fix setting validator getter --- packages/dev-env/src/moderator-client.ts | 38 ++++ .../ozone/src/api/moderation/emitEvent.ts | 127 +++++++++-- .../ozone/src/api/setting/upsertOption.ts | 13 +- packages/ozone/src/setting/constants.ts | 1 + packages/ozone/src/setting/types.ts | 3 + packages/ozone/src/setting/validators.ts | 61 ++++++ packages/ozone/tests/protected-tags.test.ts | 201 ++++++++++++++++++ 7 files changed, 428 insertions(+), 16 deletions(-) create mode 100644 packages/ozone/src/setting/constants.ts create mode 100644 packages/ozone/src/setting/types.ts create mode 100644 packages/ozone/src/setting/validators.ts create mode 100644 packages/ozone/tests/protected-tags.test.ts diff --git a/packages/dev-env/src/moderator-client.ts b/packages/dev-env/src/moderator-client.ts index 6327e15147e..6c80fdeca9c 100644 --- a/packages/dev-env/src/moderator-client.ts +++ b/packages/dev-env/src/moderator-client.ts @@ -3,6 +3,8 @@ import { ToolsOzoneModerationEmitEvent as EmitModerationEvent, ToolsOzoneModerationQueryStatuses as QueryModerationStatuses, ToolsOzoneModerationQueryEvents as QueryModerationEvents, + ToolsOzoneSettingUpsertOption, + ToolsOzoneSettingRemoveOptions, } from '@atproto/api' import { TestOzone } from './ozone' @@ -156,4 +158,40 @@ export class ModeratorClient { role, ) } + + async upsertSettingOption( + setting: ToolsOzoneSettingUpsertOption.InputSchema, + callerRole: 'admin' | 'moderator' | 'triage' = 'admin', + ) { + const { data } = await this.agent.tools.ozone.setting.upsertOption( + setting, + { + encoding: 'application/json', + headers: await this.ozone.modHeaders( + 'tools.ozone.setting.upsertOption', + callerRole, + ), + }, + ) + + return data + } + + async removeSettingOptions( + params: ToolsOzoneSettingRemoveOptions.InputSchema, + callerRole: 'admin' | 'moderator' | 'triage' = 'admin', + ) { + const { data } = await this.agent.tools.ozone.setting.removeOptions( + params, + { + encoding: 'application/json', + headers: await this.ozone.modHeaders( + 'tools.ozone.setting.removeOptions', + callerRole, + ), + }, + ) + + return data + } } diff --git a/packages/ozone/src/api/moderation/emitEvent.ts b/packages/ozone/src/api/moderation/emitEvent.ts index e8c98e416b9..5f5c848e0ab 100644 --- a/packages/ozone/src/api/moderation/emitEvent.ts +++ b/packages/ozone/src/api/moderation/emitEvent.ts @@ -17,6 +17,9 @@ import { subjectFromInput } from '../../mod-service/subject' import { TagService } from '../../tag-service' import { retryHttp } from '../../util' import { ModeratorOutput, AdminTokenOutput } from '../../auth-verifier' +import { SettingService } from '../../setting/service' +import { ProtectedTagSettingKey } from '../../setting/constants' +import { ProtectedTagSetting } from '../../setting/types' const handleModerationEvent = async ({ ctx, @@ -34,6 +37,7 @@ const handleModerationEvent = async ({ : input.body.createdBy const db = ctx.db const moderationService = ctx.modService(db) + const settingService = ctx.settingService(db) const { event } = input.body const isTakedownEvent = isModEventTakedown(event) const isReverseTakedownEvent = isModEventReverseTakedown(event) @@ -86,6 +90,59 @@ const handleModerationEvent = async ({ throw new InvalidRequestError(`Subject is not taken down`) } + if (status?.tags?.length) { + const protectedTags = await getProtectedTags( + settingService, + ctx.cfg.service.did, + ) + + if (protectedTags) { + status.tags.forEach((tag) => { + if (!Object.hasOwn(protectedTags, tag)) return + if ( + protectedTags[tag]['moderators'] && + !protectedTags[tag]['moderators'].includes(createdBy) + ) { + throw new InvalidRequestError( + `Not allowed to action on protected tag: ${tag}`, + ) + } + if (protectedTags[tag]['roles']) { + if ( + auth.credentials.isAdmin && + !protectedTags[tag]['roles'].includes( + 'tools.ozone.team.defs#roleAdmin', + ) + ) { + throw new InvalidRequestError( + `Not allowed to action on protected tag: ${tag}`, + ) + } + if ( + auth.credentials.isModerator && + !protectedTags[tag]['roles'].includes( + 'tools.ozone.team.defs#roleModerator', + ) + ) { + throw new InvalidRequestError( + `Not allowed to action on protected tag: ${tag}`, + ) + } + if ( + auth.credentials.isTriage && + !protectedTags[tag]['roles'].includes( + 'tools.ozone.team.defs#roleTriage', + ) + ) { + throw new InvalidRequestError( + `Not allowed to action on protected tag: ${tag}`, + ) + } + } + }) + } + } + if (status?.takendown && isReverseTakedownEvent && subject.isRecord()) { // due to the way blob status is modeled, we should reverse takedown on all // blobs for the record being restored, which aren't taken down on another record. @@ -125,7 +182,7 @@ const handleModerationEvent = async ({ } if (isModEventTag(event)) { - assertTagAuth(event, auth) + await assertTagAuth(settingService, ctx.cfg.service.did, event, auth) } const moderationEvent = await db.transaction(async (dbTxn) => { @@ -225,31 +282,75 @@ export default function (server: Server, ctx: AppContext) { }) } -const TAG_AUTH: Record = { - 'chat-disabled': 'moderator', -} - -const assertTagAuth = ( +const assertTagAuth = async ( + settingService: SettingService, + serviceDid: string, event: ModEventTag, auth: ModeratorOutput | AdminTokenOutput, ) => { // admins can add/remove any tag if (auth.credentials.isAdmin) return - for (const tag of Object.keys(TAG_AUTH)) { + const protectedTags = await getProtectedTags(settingService, serviceDid) + + if (!protectedTags) { + return + } + + for (const tag of Object.keys(protectedTags)) { if (event.add.includes(tag) || event.remove.includes(tag)) { - if (TAG_AUTH[tag] === 'admin' && !auth.credentials.isAdmin) { - throw new Error(`Must be an admin to add tag: ${tag}`) - } else if ( - TAG_AUTH[tag] === 'moderator' && - !auth.credentials.isModerator + // if specific moderators are configured to manage this tag but the current user + // is not one of them, then throw an error + const configuredModerators = protectedTags[tag]?.['moderators'] + if ( + configuredModerators && + !configuredModerators.includes(auth.credentials.iss) ) { - throw new Error(`Must be a full moderator to add tag: ${tag}`) + throw new InvalidRequestError(`Not allowed to manage tag: ${tag}`) + } + + const configuredRoles = protectedTags[tag]?.['roles'] + if (configuredRoles) { + // admins can already do everything so we only check for moderator and triage role config + if ( + auth.credentials.isModerator && + !configuredRoles.includes('tools.ozone.team.defs#roleModerator') + ) { + throw new InvalidRequestError( + `Can not manage tag ${tag} with moderator role`, + ) + } else if ( + auth.credentials.isTriage && + !configuredRoles.includes('tools.ozone.team.defs#roleTriage') + ) { + throw new InvalidRequestError( + `Can not manage tag ${tag} with triage role`, + ) + } } } } } +const getProtectedTags = async ( + settingService: SettingService, + serviceDid: string, +) => { + const protectedTagSetting = await settingService.query({ + keys: [ProtectedTagSettingKey], + scope: 'instance', + did: serviceDid, + limit: 1, + }) + + // if no protected tags are configured, then no need to do further check + if (!protectedTagSetting.options.length) { + return + } + + return protectedTagSetting.options[0].value as ProtectedTagSetting +} + const validateLabels = (labels: string[]) => { for (const label of labels) { for (const char of badChars) { diff --git a/packages/ozone/src/api/setting/upsertOption.ts b/packages/ozone/src/api/setting/upsertOption.ts index dc524c7e457..408e9b32da9 100644 --- a/packages/ozone/src/api/setting/upsertOption.ts +++ b/packages/ozone/src/api/setting/upsertOption.ts @@ -6,6 +6,7 @@ import { SettingService } from '../../setting/service' import { Member } from '../../db/schema/member' import { ToolsOzoneTeamDefs } from '@atproto/api' import assert from 'node:assert' +import { settingValidators } from '../../setting/validators' export default function (server: Server, ctx: AppContext) { server.tools.ozone.setting.upsertOption({ @@ -63,11 +64,17 @@ export default function (server: Server, ctx: AppContext) { ) { throw new AuthRequiredError(`Not permitted to update setting ${key}`) } - await settingService.upsert({ + const option = { ...baseOption, - scope: 'instance', + scope: 'instance' as const, managerRole: getManagerRole(managerRole), - }) + } + + if (settingValidators.has(key)) { + await settingValidators.get(key)?.(option) + } + + await settingService.upsert(option) } const newOption = await getExistingSetting( diff --git a/packages/ozone/src/setting/constants.ts b/packages/ozone/src/setting/constants.ts new file mode 100644 index 00000000000..c98bace515f --- /dev/null +++ b/packages/ozone/src/setting/constants.ts @@ -0,0 +1 @@ +export const ProtectedTagSettingKey = 'tools.ozone.setting.protectedTags' diff --git a/packages/ozone/src/setting/types.ts b/packages/ozone/src/setting/types.ts new file mode 100644 index 00000000000..ab90e9ca67f --- /dev/null +++ b/packages/ozone/src/setting/types.ts @@ -0,0 +1,3 @@ +export type ProtectedTagSetting = { + [key: string]: { roles?: string[]; moderators?: string[] } +} diff --git a/packages/ozone/src/setting/validators.ts b/packages/ozone/src/setting/validators.ts new file mode 100644 index 00000000000..e5c3cba2643 --- /dev/null +++ b/packages/ozone/src/setting/validators.ts @@ -0,0 +1,61 @@ +import { Selectable } from 'kysely' +import { Setting } from '../db/schema/setting' +import { ProtectedTagSettingKey } from './constants' +import { InvalidRequestError } from '@atproto/xrpc-server' + +export const settingValidators = new Map< + string, + (setting: Partial>) => Promise +>([ + [ + ProtectedTagSettingKey, + async (setting: Partial>) => { + if (setting.managerRole !== 'tools.ozone.team.defs#roleAdmin') { + throw new InvalidRequestError( + 'Only admins should be able to configure protected tags', + ) + } + + if (typeof setting.value !== 'object') { + throw new InvalidRequestError('Invalid value') + } + for (const [key, val] of Object.entries(setting.value)) { + if (!val || typeof val !== 'object') { + throw new InvalidRequestError(`Invalid configuration for tag ${key}`) + } + + if (!val['roles'] && !val['moderators']) { + throw new InvalidRequestError( + `Must define who a list of moderators or a role who can action subjects with ${key} tag`, + ) + } + + if (val['roles']) { + if (!Array.isArray(val['roles'])) { + throw new InvalidRequestError( + `Roles must be an array of moderator roles for tag ${key}`, + ) + } + if (!val['roles']?.length) { + throw new InvalidRequestError( + `Must define at least one role for tag ${key}`, + ) + } + } + + if (val['moderators']) { + if (!Array.isArray(val['moderators'])) { + throw new InvalidRequestError( + `Moderators must be an array of moderator DIDs for tag ${key}`, + ) + } + if (!val['moderators']?.length) { + throw new InvalidRequestError( + `Must define at least one moderator DID for tag ${key}`, + ) + } + } + } + }, + ], +]) diff --git a/packages/ozone/tests/protected-tags.test.ts b/packages/ozone/tests/protected-tags.test.ts new file mode 100644 index 00000000000..2f21100a559 --- /dev/null +++ b/packages/ozone/tests/protected-tags.test.ts @@ -0,0 +1,201 @@ +import { + TestNetwork, + SeedClient, + basicSeed, + ModeratorClient, +} from '@atproto/dev-env' +import { ProtectedTagSettingKey } from '../src/setting/constants' +import { + ROLEADMIN, + ROLEMODERATOR, +} from '../dist/lexicon/types/tools/ozone/team/defs' + +describe('protected-tags', () => { + let network: TestNetwork + let sc: SeedClient + let modClient: ModeratorClient + const basicSetting = { + key: ProtectedTagSettingKey, + scope: 'instance', + } + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'ozone_protected_tags', + }) + sc = network.getSeedClient() + modClient = network.ozone.getModClient() + await basicSeed(sc) + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + describe('Settings management', () => { + it('validates settings', async () => { + await expect( + modClient.upsertSettingOption({ + ...basicSetting, + managerRole: ROLEMODERATOR, + value: { + vip: {}, + }, + }), + ).rejects.toThrow( + 'Only admins should be able to configure protected tags', + ) + await expect( + modClient.upsertSettingOption({ + ...basicSetting, + managerRole: ROLEADMIN, + value: ['test'], + }), + ).rejects.toThrow('Invalid configuration') + await expect( + modClient.upsertSettingOption({ + ...basicSetting, + managerRole: ROLEADMIN, + value: { vip: 'test' }, + }), + ).rejects.toThrow('Invalid configuration') + await expect( + modClient.upsertSettingOption({ + ...basicSetting, + managerRole: ROLEADMIN, + value: { vip: { weirdValue: 1 } }, + }), + ).rejects.toThrow(/Must define who a list of moderators or a role/gi) + await expect( + modClient.upsertSettingOption({ + ...basicSetting, + managerRole: ROLEADMIN, + value: { vip: { roles: 'test' } }, + }), + ).rejects.toThrow(/Roles must be an array of moderator/gi) + await expect( + modClient.upsertSettingOption({ + ...basicSetting, + managerRole: ROLEADMIN, + value: { vip: { roles: 'test' } }, + }), + ).rejects.toThrow(/Roles must be an array of moderator/gi) + await expect( + modClient.upsertSettingOption({ + ...basicSetting, + managerRole: ROLEADMIN, + value: { vip: { moderators: 1 } }, + }), + ).rejects.toThrow(/Moderators must be an array of moderator/gi) + }) + }) + describe('Protected subject via tags', () => { + afterEach(async () => { + await modClient.removeSettingOptions({ + keys: [ProtectedTagSettingKey], + scope: 'instance', + }) + }) + it('only allows configured roles to add/remove protected tags', async () => { + await modClient.upsertSettingOption({ + ...basicSetting, + managerRole: ROLEADMIN, + value: { vip: { roles: ['tools.ozone.team.defs#roleAdmin'] } }, + }) + + await expect( + modClient.emitEvent({ + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + }, + event: { + $type: 'tools.ozone.moderation.defs#modEventTag', + add: ['vip'], + remove: [], + }, + }), + ).rejects.toThrow(/Can not manage tag vip/gi) + + await modClient.emitEvent( + { + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + }, + event: { + $type: 'tools.ozone.moderation.defs#modEventTag', + add: ['vip'], + remove: [], + }, + }, + 'admin', + ) + await expect( + modClient.emitEvent({ + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + }, + event: { + $type: 'tools.ozone.moderation.defs#modEventTag', + add: [], + remove: ['vip'], + }, + }), + ).rejects.toThrow(/Can not manage tag vip/gi) + }) + it('only allows configured moderators to add/remove protected tags', async () => { + await modClient.upsertSettingOption({ + ...basicSetting, + managerRole: ROLEADMIN, + value: { vip: { moderators: [network.ozone.adminAccnt.did] } }, + }) + + // By default, this query is made with moderator account's did + await expect( + modClient.emitEvent({ + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + }, + event: { + $type: 'tools.ozone.moderation.defs#modEventTag', + add: ['vip'], + remove: [], + }, + }), + ).rejects.toThrow(/Not allowed to manage tag: vip/gi) + + await modClient.emitEvent( + { + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + }, + event: { + $type: 'tools.ozone.moderation.defs#modEventTag', + add: ['vip'], + remove: [], + }, + }, + 'admin', + ) + + await expect( + modClient.emitEvent({ + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + }, + event: { + $type: 'tools.ozone.moderation.defs#modEventTag', + add: [], + remove: ['vip'], + }, + }), + ).rejects.toThrow(/Not allowed to manage tag: vip/gi) + }) + }) +})