From 53621f8e100a3aa3c1caff10a08d3f4ea919875a Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Fri, 3 Jan 2025 01:46:53 +0000 Subject: [PATCH] :sparkles: Add a policy property to takedown events (#3271) * :sparkles: Add a policy property to takedown events * :sparkles: Add policy list setting validation * :sparkles: Make multiple policies possible for takedown and event search * :memo: Add changeset * :sparkles: Use , as policies separator --- .changeset/smooth-steaks-suffer.md | 7 ++ lexicons/tools/ozone/moderation/defs.json | 6 ++ .../tools/ozone/moderation/queryEvents.json | 7 ++ packages/api/src/client/lexicons.ts | 17 +++++ .../types/tools/ozone/moderation/defs.ts | 2 + .../tools/ozone/moderation/queryEvents.ts | 1 + packages/dev-env/src/moderator-client.ts | 5 +- .../ozone/src/api/moderation/queryEvents.ts | 2 + packages/ozone/src/lexicon/lexicons.ts | 17 +++++ .../types/tools/ozone/moderation/defs.ts | 2 + .../tools/ozone/moderation/queryEvents.ts | 1 + packages/ozone/src/mod-service/index.ts | 14 ++++ packages/ozone/src/mod-service/views.ts | 11 ++++ packages/ozone/src/setting/constants.ts | 1 + packages/ozone/src/setting/validators.ts | 29 ++++++++- packages/ozone/tests/takedown.test.ts | 64 +++++++++++++++++++ packages/pds/src/lexicon/lexicons.ts | 17 +++++ .../types/tools/ozone/moderation/defs.ts | 2 + .../tools/ozone/moderation/queryEvents.ts | 1 + 19 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 .changeset/smooth-steaks-suffer.md create mode 100644 packages/ozone/tests/takedown.test.ts diff --git a/.changeset/smooth-steaks-suffer.md b/.changeset/smooth-steaks-suffer.md new file mode 100644 index 00000000000..def5a068cd6 --- /dev/null +++ b/.changeset/smooth-steaks-suffer.md @@ -0,0 +1,7 @@ +--- +"@atproto/dev-env": patch +"@atproto/ozone": patch +"@atproto/api": patch +--- + +Allow setting policy names with takedown actions and when querying events diff --git a/lexicons/tools/ozone/moderation/defs.json b/lexicons/tools/ozone/moderation/defs.json index c9fa7115e2c..7c590cddb3a 100644 --- a/lexicons/tools/ozone/moderation/defs.json +++ b/lexicons/tools/ozone/moderation/defs.json @@ -224,6 +224,12 @@ "acknowledgeAccountSubjects": { "type": "boolean", "description": "If true, all other reports on content authored by this account will be resolved (acknowledged)." + }, + "policies": { + "type": "array", + "maxLength": 5, + "items": { "type": "string" }, + "description": "Names/Keywords of the policies that drove the decision." } } }, diff --git a/lexicons/tools/ozone/moderation/queryEvents.json b/lexicons/tools/ozone/moderation/queryEvents.json index 9e89a94830a..e33e18c6d35 100644 --- a/lexicons/tools/ozone/moderation/queryEvents.json +++ b/lexicons/tools/ozone/moderation/queryEvents.json @@ -106,6 +106,13 @@ "type": "string" } }, + "policies": { + "type": "array", + "items": { + "type": "string", + "description": "If specified, only events where the action policies match any of the given policies are returned" + } + }, "cursor": { "type": "string" } diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index ef408e82ad0..3e539c106b7 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -11309,6 +11309,15 @@ export const schemaDict = { description: 'If true, all other reports on content authored by this account will be resolved (acknowledged).', }, + policies: { + type: 'array', + maxLength: 5, + items: { + type: 'string', + }, + description: + 'Names/Keywords of the policies that drove the decision.', + }, }, }, modEventReverseTakedown: { @@ -12345,6 +12354,14 @@ export const schemaDict = { type: 'string', }, }, + policies: { + type: 'array', + items: { + type: 'string', + description: + 'If specified, only events where the policy matches the given policy are returned', + }, + }, cursor: { type: 'string', }, diff --git a/packages/api/src/client/types/tools/ozone/moderation/defs.ts b/packages/api/src/client/types/tools/ozone/moderation/defs.ts index 1fa1004b1fe..fd58278374b 100644 --- a/packages/api/src/client/types/tools/ozone/moderation/defs.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/defs.ts @@ -174,6 +174,8 @@ export interface ModEventTakedown { durationInHours?: number /** If true, all other reports on content authored by this account will be resolved (acknowledged). */ acknowledgeAccountSubjects?: boolean + /** Names/Keywords of the policies that drove the decision. */ + policies?: string[] [k: string]: unknown } diff --git a/packages/api/src/client/types/tools/ozone/moderation/queryEvents.ts b/packages/api/src/client/types/tools/ozone/moderation/queryEvents.ts index a73c5819ce4..c65581b4ebc 100644 --- a/packages/api/src/client/types/tools/ozone/moderation/queryEvents.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/queryEvents.ts @@ -39,6 +39,7 @@ export interface QueryParams { /** If specified, only events where all of these tags were removed are returned */ removedTags?: string[] reportTypes?: string[] + policies?: string[] cursor?: string } diff --git a/packages/dev-env/src/moderator-client.ts b/packages/dev-env/src/moderator-client.ts index 6c80fdeca9c..eb33cc0d566 100644 --- a/packages/dev-env/src/moderator-client.ts +++ b/packages/dev-env/src/moderator-client.ts @@ -123,16 +123,19 @@ export class ModeratorClient { durationInHours?: number acknowledgeAccountSubjects?: boolean reason?: string + policies?: string[] }, role?: ModLevel, ) { - const { durationInHours, acknowledgeAccountSubjects, ...rest } = opts + const { durationInHours, acknowledgeAccountSubjects, policies, ...rest } = + opts return this.emitEvent( { event: { $type: 'tools.ozone.moderation.defs#modEventTakedown', acknowledgeAccountSubjects, durationInHours, + policies, }, ...rest, }, diff --git a/packages/ozone/src/api/moderation/queryEvents.ts b/packages/ozone/src/api/moderation/queryEvents.ts index 70b5f056f16..9f8f58f3ede 100644 --- a/packages/ozone/src/api/moderation/queryEvents.ts +++ b/packages/ozone/src/api/moderation/queryEvents.ts @@ -25,6 +25,7 @@ export default function (server: Server, ctx: AppContext) { reportTypes, collections = [], subjectType, + policies, } = params const db = ctx.db const modService = ctx.modService(db) @@ -47,6 +48,7 @@ export default function (server: Server, ctx: AppContext) { reportTypes, collections, subjectType, + policies, }) return { encoding: 'application/json', diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index ef408e82ad0..3e539c106b7 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -11309,6 +11309,15 @@ export const schemaDict = { description: 'If true, all other reports on content authored by this account will be resolved (acknowledged).', }, + policies: { + type: 'array', + maxLength: 5, + items: { + type: 'string', + }, + description: + 'Names/Keywords of the policies that drove the decision.', + }, }, }, modEventReverseTakedown: { @@ -12345,6 +12354,14 @@ export const schemaDict = { type: 'string', }, }, + policies: { + type: 'array', + items: { + type: 'string', + description: + 'If specified, only events where the policy matches the given policy are returned', + }, + }, cursor: { type: 'string', }, diff --git a/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts b/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts index 403f7832a6d..7397fafc19d 100644 --- a/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts +++ b/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts @@ -174,6 +174,8 @@ export interface ModEventTakedown { durationInHours?: number /** If true, all other reports on content authored by this account will be resolved (acknowledged). */ acknowledgeAccountSubjects?: boolean + /** Names/Keywords of the policies that drove the decision. */ + policies?: string[] [k: string]: unknown } diff --git a/packages/ozone/src/lexicon/types/tools/ozone/moderation/queryEvents.ts b/packages/ozone/src/lexicon/types/tools/ozone/moderation/queryEvents.ts index 3dff4ca7cf7..51003984889 100644 --- a/packages/ozone/src/lexicon/types/tools/ozone/moderation/queryEvents.ts +++ b/packages/ozone/src/lexicon/types/tools/ozone/moderation/queryEvents.ts @@ -40,6 +40,7 @@ export interface QueryParams { /** If specified, only events where all of these tags were removed are returned */ removedTags?: string[] reportTypes?: string[] + policies?: string[] cursor?: string } diff --git a/packages/ozone/src/mod-service/index.ts b/packages/ozone/src/mod-service/index.ts index 978015f9d86..cfc4bf76fa9 100644 --- a/packages/ozone/src/mod-service/index.ts +++ b/packages/ozone/src/mod-service/index.ts @@ -152,6 +152,7 @@ export class ModerationService { reportTypes?: string[] collections: string[] subjectType?: string + policies?: string[] }): Promise<{ cursor?: string; events: ModerationEventRow[] }> { const { subject, @@ -172,6 +173,7 @@ export class ModerationService { reportTypes, collections, subjectType, + policies, } = opts const { ref } = this.db.db.dynamic let builder = this.db.db.selectFrom('moderation_event').selectAll() @@ -264,6 +266,14 @@ export class ModerationService { if (reportTypes?.length) { builder = builder.where(sql`meta->>'reportType'`, 'in', reportTypes) } + if (policies?.length) { + builder = builder.where((qb) => { + policies.forEach((policy) => { + qb = qb.orWhere(sql`meta->>'policies'`, 'ilike', `%${policy}%`) + }) + return qb + }) + } const keyset = new TimeIdKeyset( ref(`moderation_event.createdAt`), @@ -435,6 +445,10 @@ export class ModerationService { meta.acknowledgeAccountSubjects = true } + if (isModEventTakedown(event) && event.policies?.length) { + meta.policies = event.policies.join(',') + } + // Keep trace of reports that came in while the reporter was in muted stated if (isModEventReport(event)) { const isReportingMuted = await this.isReportingMutedForSubject(createdBy) diff --git a/packages/ozone/src/mod-service/views.ts b/packages/ozone/src/mod-service/views.ts index d4b0deafc33..7fde08ca470 100644 --- a/packages/ozone/src/mod-service/views.ts +++ b/packages/ozone/src/mod-service/views.ts @@ -137,6 +137,17 @@ export class ModerationViews { } } + if ( + event.action === 'tools.ozone.moderation.defs#modEventTakedown' && + typeof event.meta?.policies === 'string' && + event.meta.policies.length > 0 + ) { + eventView.event = { + ...eventView.event, + policies: event.meta.policies.split(','), + } + } + if (event.action === 'tools.ozone.moderation.defs#modEventLabel') { eventView.event = { ...eventView.event, diff --git a/packages/ozone/src/setting/constants.ts b/packages/ozone/src/setting/constants.ts index c98bace515f..4889c32be5b 100644 --- a/packages/ozone/src/setting/constants.ts +++ b/packages/ozone/src/setting/constants.ts @@ -1 +1,2 @@ export const ProtectedTagSettingKey = 'tools.ozone.setting.protectedTags' +export const PolicyListSettingKey = 'tools.ozone.setting.policyList' diff --git a/packages/ozone/src/setting/validators.ts b/packages/ozone/src/setting/validators.ts index e5c3cba2643..124728ec43b 100644 --- a/packages/ozone/src/setting/validators.ts +++ b/packages/ozone/src/setting/validators.ts @@ -1,6 +1,6 @@ import { Selectable } from 'kysely' import { Setting } from '../db/schema/setting' -import { ProtectedTagSettingKey } from './constants' +import { PolicyListSettingKey, ProtectedTagSettingKey } from './constants' import { InvalidRequestError } from '@atproto/xrpc-server' export const settingValidators = new Map< @@ -58,4 +58,31 @@ export const settingValidators = new Map< } }, ], + [ + PolicyListSettingKey, + async (setting: Partial>) => { + if (setting.managerRole !== 'tools.ozone.team.defs#roleAdmin') { + throw new InvalidRequestError( + 'Only admins should be able to manage policy list', + ) + } + + 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 policy ${key}`, + ) + } + + if (!val['name'] || !val['description']) { + throw new InvalidRequestError( + `Must define a name and description for policy ${key}`, + ) + } + } + }, + ], ]) diff --git a/packages/ozone/tests/takedown.test.ts b/packages/ozone/tests/takedown.test.ts new file mode 100644 index 00000000000..8cb7ed5763b --- /dev/null +++ b/packages/ozone/tests/takedown.test.ts @@ -0,0 +1,64 @@ +import { + TestNetwork, + TestOzone, + SeedClient, + basicSeed, + ModeratorClient, +} from '@atproto/dev-env' +import { AtpAgent } from '@atproto/api' + +describe('moderation', () => { + let network: TestNetwork + let ozone: TestOzone + let agent: AtpAgent + let bskyAgent: AtpAgent + let pdsAgent: AtpAgent + let sc: SeedClient + let modClient: ModeratorClient + + const repoSubject = (did: string) => ({ + $type: 'com.atproto.admin.defs#repoRef', + did, + }) + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'ozone_takedown', + }) + ozone = network.ozone + agent = network.ozone.getClient() + bskyAgent = network.bsky.getClient() + pdsAgent = network.pds.getClient() + sc = network.getSeedClient() + modClient = network.ozone.getModClient() + await basicSeed(sc) + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + it('allows specifying policy for takedown actions.', async () => { + await modClient.performTakedown({ + subject: repoSubject(sc.dids.bob), + policies: ['trolling'], + }) + + // Verify that that the takedown even exposes the policy specified for it + const { events } = await modClient.queryEvents({ + subject: sc.dids.bob, + types: ['tools.ozone.moderation.defs#modEventTakedown'], + }) + + expect(events[0].event.policies?.[0]).toEqual('trolling') + + // Verify that event stream can be filtered by policy + const { events: filteredEvents } = await modClient.queryEvents({ + subject: sc.dids.bob, + policies: ['trolling'], + }) + + expect(filteredEvents[0].subject.did).toEqual(sc.dids.bob) + }) +}) diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index ef408e82ad0..3e539c106b7 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -11309,6 +11309,15 @@ export const schemaDict = { description: 'If true, all other reports on content authored by this account will be resolved (acknowledged).', }, + policies: { + type: 'array', + maxLength: 5, + items: { + type: 'string', + }, + description: + 'Names/Keywords of the policies that drove the decision.', + }, }, }, modEventReverseTakedown: { @@ -12345,6 +12354,14 @@ export const schemaDict = { type: 'string', }, }, + policies: { + type: 'array', + items: { + type: 'string', + description: + 'If specified, only events where the policy matches the given policy are returned', + }, + }, cursor: { type: 'string', }, diff --git a/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts b/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts index 403f7832a6d..7397fafc19d 100644 --- a/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts +++ b/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts @@ -174,6 +174,8 @@ export interface ModEventTakedown { durationInHours?: number /** If true, all other reports on content authored by this account will be resolved (acknowledged). */ acknowledgeAccountSubjects?: boolean + /** Names/Keywords of the policies that drove the decision. */ + policies?: string[] [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/tools/ozone/moderation/queryEvents.ts b/packages/pds/src/lexicon/types/tools/ozone/moderation/queryEvents.ts index 3dff4ca7cf7..51003984889 100644 --- a/packages/pds/src/lexicon/types/tools/ozone/moderation/queryEvents.ts +++ b/packages/pds/src/lexicon/types/tools/ozone/moderation/queryEvents.ts @@ -40,6 +40,7 @@ export interface QueryParams { /** If specified, only events where all of these tags were removed are returned */ removedTags?: string[] reportTypes?: string[] + policies?: string[] cursor?: string }