diff --git a/lexicons/tools/ozone/moderation/defs.json b/lexicons/tools/ozone/moderation/defs.json index c9fa7115e2c..96a2fa12bdd 100644 --- a/lexicons/tools/ozone/moderation/defs.json +++ b/lexicons/tools/ozone/moderation/defs.json @@ -224,6 +224,10 @@ "acknowledgeAccountSubjects": { "type": "boolean", "description": "If true, all other reports on content authored by this account will be resolved (acknowledged)." + }, + "policy": { + "type": "string", + "description": "Name/Keyword of the policy that drove the decision." } } }, diff --git a/lexicons/tools/ozone/moderation/queryEvents.json b/lexicons/tools/ozone/moderation/queryEvents.json index 9e89a94830a..489285eb479 100644 --- a/lexicons/tools/ozone/moderation/queryEvents.json +++ b/lexicons/tools/ozone/moderation/queryEvents.json @@ -106,6 +106,10 @@ "type": "string" } }, + "policy": { + "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/lexicons.ts b/packages/api/src/client/lexicons.ts index 0e21313d62b..267946b6fa8 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -11304,6 +11304,10 @@ export const schemaDict = { description: 'If true, all other reports on content authored by this account will be resolved (acknowledged).', }, + policy: { + type: 'string', + description: 'Name/Keyword of the policy that drove the decision.', + }, }, }, modEventReverseTakedown: { @@ -12340,6 +12344,11 @@ export const schemaDict = { type: 'string', }, }, + policy: { + 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..288f6cc8c27 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 + /** Name/Keyword of the policy that drove the decision. */ + policy?: 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..450b2cf5b0d 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,8 @@ export interface QueryParams { /** If specified, only events where all of these tags were removed are returned */ removedTags?: string[] reportTypes?: string[] + /** If specified, only events where the policy matches the given policy are returned */ + policy?: string cursor?: string } diff --git a/packages/dev-env/src/moderator-client.ts b/packages/dev-env/src/moderator-client.ts index 6c80fdeca9c..d4d06169e14 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 + policy?: string }, role?: ModLevel, ) { - const { durationInHours, acknowledgeAccountSubjects, ...rest } = opts + const { durationInHours, acknowledgeAccountSubjects, policy, ...rest } = + opts return this.emitEvent( { event: { $type: 'tools.ozone.moderation.defs#modEventTakedown', acknowledgeAccountSubjects, durationInHours, + policy, }, ...rest, }, diff --git a/packages/ozone/src/api/moderation/queryEvents.ts b/packages/ozone/src/api/moderation/queryEvents.ts index 70b5f056f16..624396daa42 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, + policy, } = params const db = ctx.db const modService = ctx.modService(db) @@ -47,6 +48,7 @@ export default function (server: Server, ctx: AppContext) { reportTypes, collections, subjectType, + policy, }) return { encoding: 'application/json', diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index 0e21313d62b..267946b6fa8 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -11304,6 +11304,10 @@ export const schemaDict = { description: 'If true, all other reports on content authored by this account will be resolved (acknowledged).', }, + policy: { + type: 'string', + description: 'Name/Keyword of the policy that drove the decision.', + }, }, }, modEventReverseTakedown: { @@ -12340,6 +12344,11 @@ export const schemaDict = { type: 'string', }, }, + policy: { + 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..0413c57d96f 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 + /** Name/Keyword of the policy that drove the decision. */ + policy?: 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..ce3da2fd3b0 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,8 @@ export interface QueryParams { /** If specified, only events where all of these tags were removed are returned */ removedTags?: string[] reportTypes?: string[] + /** If specified, only events where the policy matches the given policy are returned */ + policy?: string cursor?: string } diff --git a/packages/ozone/src/mod-service/index.ts b/packages/ozone/src/mod-service/index.ts index 825dc721ae7..fd1eb933541 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 + policy?: string }): Promise<{ cursor?: string; events: ModerationEventRow[] }> { const { subject, @@ -172,6 +173,7 @@ export class ModerationService { reportTypes, collections, subjectType, + policy, } = opts const { ref } = this.db.db.dynamic let builder = this.db.db.selectFrom('moderation_event').selectAll() @@ -264,6 +266,9 @@ export class ModerationService { if (reportTypes?.length) { builder = builder.where(sql`meta->>'reportType'`, 'in', reportTypes) } + if (policy) { + builder = builder.where(sql`meta->>'policy'`, '=', policy) + } const keyset = new TimeIdKeyset( ref(`moderation_event.createdAt`), @@ -435,6 +440,10 @@ export class ModerationService { meta.acknowledgeAccountSubjects = true } + if (isModEventTakedown(event) && event.policy) { + meta.policy = event.policy + } + // 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..e575542af87 100644 --- a/packages/ozone/src/mod-service/views.ts +++ b/packages/ozone/src/mod-service/views.ts @@ -137,6 +137,16 @@ export class ModerationViews { } } + if ( + event.action === 'tools.ozone.moderation.defs#modEventTakedown' && + event.meta?.policy + ) { + eventView.event = { + ...eventView.event, + policy: event.meta.policy, + } + } + 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..a386eb3503a --- /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), + policy: '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.policy).toEqual('trolling') + + // Verify that event stream can be filtered by policy + const { events: filteredEvents } = await modClient.queryEvents({ + subject: sc.dids.bob, + policy: '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 0e21313d62b..267946b6fa8 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -11304,6 +11304,10 @@ export const schemaDict = { description: 'If true, all other reports on content authored by this account will be resolved (acknowledged).', }, + policy: { + type: 'string', + description: 'Name/Keyword of the policy that drove the decision.', + }, }, }, modEventReverseTakedown: { @@ -12340,6 +12344,11 @@ export const schemaDict = { type: 'string', }, }, + policy: { + 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..0413c57d96f 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 + /** Name/Keyword of the policy that drove the decision. */ + policy?: 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..ce3da2fd3b0 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,8 @@ export interface QueryParams { /** If specified, only events where all of these tags were removed are returned */ removedTags?: string[] reportTypes?: string[] + /** If specified, only events where the policy matches the given policy are returned */ + policy?: string cursor?: string }