Skip to content

Commit

Permalink
✨ Add protected tag setting (bluesky-social#3050)
Browse files Browse the repository at this point in the history
* ✨ Add protected tag setting

* ✅ Add tests for protected tag options

* ✨ Validate mod and role list

* 🧹 Replace usage of objects with Map

* 🐛 Fix setting validator getter
  • Loading branch information
foysalit authored Nov 22, 2024
1 parent 7b16a86 commit 011e73e
Show file tree
Hide file tree
Showing 7 changed files with 428 additions and 16 deletions.
38 changes: 38 additions & 0 deletions packages/dev-env/src/moderator-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
ToolsOzoneModerationEmitEvent as EmitModerationEvent,
ToolsOzoneModerationQueryStatuses as QueryModerationStatuses,
ToolsOzoneModerationQueryEvents as QueryModerationEvents,
ToolsOzoneSettingUpsertOption,
ToolsOzoneSettingRemoveOptions,
} from '@atproto/api'
import { TestOzone } from './ozone'

Expand Down Expand Up @@ -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
}
}
127 changes: 114 additions & 13 deletions packages/ozone/src/api/moderation/emitEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -225,31 +282,75 @@ export default function (server: Server, ctx: AppContext) {
})
}

const TAG_AUTH: Record<string, 'triage' | 'moderator' | 'admin'> = {
'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) {
Expand Down
13 changes: 10 additions & 3 deletions packages/ozone/src/api/setting/upsertOption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/ozone/src/setting/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ProtectedTagSettingKey = 'tools.ozone.setting.protectedTags'
3 changes: 3 additions & 0 deletions packages/ozone/src/setting/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type ProtectedTagSetting = {
[key: string]: { roles?: string[]; moderators?: string[] }
}
61 changes: 61 additions & 0 deletions packages/ozone/src/setting/validators.ts
Original file line number Diff line number Diff line change
@@ -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<Selectable<Setting>>) => Promise<void>
>([
[
ProtectedTagSettingKey,
async (setting: Partial<Selectable<Setting>>) => {
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}`,
)
}
}
}
},
],
])
Loading

0 comments on commit 011e73e

Please sign in to comment.