From 8bdb00a0e2570c090f6830c06c06017f44aa3b1f Mon Sep 17 00:00:00 2001 From: devin ivy Date: Fri, 21 Apr 2023 20:58:34 -0400 Subject: [PATCH] PDS moderator credentials (#863) * Setup config and auth verifiers for moderators * Enforce admin vs. moderator access on PDS admin/server endpoints * Tidy --- .../api/com/atproto/admin/getInviteCodes.ts | 2 +- .../com/atproto/admin/getModerationAction.ts | 2 +- .../com/atproto/admin/getModerationActions.ts | 2 +- .../com/atproto/admin/getModerationReport.ts | 2 +- .../com/atproto/admin/getModerationReports.ts | 2 +- .../src/api/com/atproto/admin/getRecord.ts | 2 +- .../pds/src/api/com/atproto/admin/getRepo.ts | 2 +- .../atproto/admin/resolveModerationReports.ts | 2 +- .../src/api/com/atproto/admin/searchRepos.ts | 2 +- .../com/atproto/admin/takeModerationAction.ts | 17 +++++-- packages/pds/src/auth.ts | 34 ++++++++++---- packages/pds/src/config.ts | 7 +++ packages/pds/src/context.ts | 4 ++ packages/pds/src/index.ts | 1 + packages/pds/tests/_util.ts | 12 ++++- packages/pds/tests/account.test.ts | 14 ++++++ packages/pds/tests/handles.test.ts | 12 +++++ packages/pds/tests/moderation.test.ts | 47 ++++++++++++++++++- .../pds/tests/views/admin/invites.test.ts | 27 ++++++++++- 19 files changed, 170 insertions(+), 23 deletions(-) diff --git a/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts index 9543b8c4c14..c5ba289032b 100644 --- a/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts @@ -10,7 +10,7 @@ import { export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getInviteCodes({ - auth: ctx.adminVerifier, + auth: ctx.moderatorVerifier, handler: async ({ params }) => { const { sort, limit, cursor } = params const ref = ctx.db.db.dynamic.ref diff --git a/packages/pds/src/api/com/atproto/admin/getModerationAction.ts b/packages/pds/src/api/com/atproto/admin/getModerationAction.ts index 4ff07ff5962..8cecab167db 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationAction.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationAction.ts @@ -3,7 +3,7 @@ import AppContext from '../../../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationAction({ - auth: ctx.adminVerifier, + auth: ctx.moderatorVerifier, handler: async ({ params }) => { const { db, services } = ctx const { id } = params diff --git a/packages/pds/src/api/com/atproto/admin/getModerationActions.ts b/packages/pds/src/api/com/atproto/admin/getModerationActions.ts index 8baa9828aa9..dfbf5e12efe 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationActions.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationActions.ts @@ -3,7 +3,7 @@ import AppContext from '../../../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationActions({ - auth: ctx.adminVerifier, + auth: ctx.moderatorVerifier, handler: async ({ params }) => { const { db, services } = ctx const { subject, limit = 50, cursor } = params diff --git a/packages/pds/src/api/com/atproto/admin/getModerationReport.ts b/packages/pds/src/api/com/atproto/admin/getModerationReport.ts index 39203e479a8..18c0c764426 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationReport.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationReport.ts @@ -3,7 +3,7 @@ import AppContext from '../../../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationReport({ - auth: ctx.adminVerifier, + auth: ctx.moderatorVerifier, handler: async ({ params }) => { const { db, services } = ctx const { id } = params diff --git a/packages/pds/src/api/com/atproto/admin/getModerationReports.ts b/packages/pds/src/api/com/atproto/admin/getModerationReports.ts index eb595c765d6..2fcb30ee5a5 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationReports.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationReports.ts @@ -3,7 +3,7 @@ import AppContext from '../../../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationReports({ - auth: ctx.adminVerifier, + auth: ctx.moderatorVerifier, handler: async ({ params }) => { const { db, services } = ctx const { subject, resolved, limit = 50, cursor } = params diff --git a/packages/pds/src/api/com/atproto/admin/getRecord.ts b/packages/pds/src/api/com/atproto/admin/getRecord.ts index 5f978fd1ee7..d5153901944 100644 --- a/packages/pds/src/api/com/atproto/admin/getRecord.ts +++ b/packages/pds/src/api/com/atproto/admin/getRecord.ts @@ -5,7 +5,7 @@ import { AtUri } from '@atproto/uri' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRecord({ - auth: ctx.adminVerifier, + auth: ctx.moderatorVerifier, handler: async ({ params }) => { const { db, services } = ctx const { uri, cid } = params diff --git a/packages/pds/src/api/com/atproto/admin/getRepo.ts b/packages/pds/src/api/com/atproto/admin/getRepo.ts index 922dc242665..1ca240428ea 100644 --- a/packages/pds/src/api/com/atproto/admin/getRepo.ts +++ b/packages/pds/src/api/com/atproto/admin/getRepo.ts @@ -4,7 +4,7 @@ import AppContext from '../../../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRepo({ - auth: ctx.adminVerifier, + auth: ctx.moderatorVerifier, handler: async ({ params }) => { const { db, services } = ctx const { did } = params diff --git a/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts b/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts index d51a09ff07e..267238e2faf 100644 --- a/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts +++ b/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts @@ -3,7 +3,7 @@ import AppContext from '../../../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.resolveModerationReports({ - auth: ctx.adminVerifier, + auth: ctx.moderatorVerifier, handler: async ({ input }) => { const { db, services } = ctx const moderationService = services.moderation(db) diff --git a/packages/pds/src/api/com/atproto/admin/searchRepos.ts b/packages/pds/src/api/com/atproto/admin/searchRepos.ts index 9efafeee625..deb93c9341c 100644 --- a/packages/pds/src/api/com/atproto/admin/searchRepos.ts +++ b/packages/pds/src/api/com/atproto/admin/searchRepos.ts @@ -6,7 +6,7 @@ import { ListKeyset } from '../../../../services/account' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.searchRepos({ - auth: ctx.adminVerifier, + auth: ctx.moderatorVerifier, handler: async ({ params }) => { const { db, services } = ctx const moderationService = services.moderation(db) diff --git a/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts b/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts index f2c6416dafb..7a0931f6871 100644 --- a/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts +++ b/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts @@ -4,12 +4,12 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { TAKEDOWN } from '../../../../lexicon/types/com/atproto/admin/defs' import { getSubject, getAction } from '../moderation/util' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.takeModerationAction({ - auth: ctx.adminVerifier, - handler: async ({ input }) => { + auth: ctx.moderatorVerifier, + handler: async ({ input, auth }) => { const { db, services } = ctx const moderationService = services.moderation(db) const { @@ -22,6 +22,17 @@ export default function (server: Server, ctx: AppContext) { subjectBlobCids, } = input.body + if ( + !auth.credentials.admin && + (createLabelVals?.length || + negateLabelVals?.length || + action === TAKEDOWN) + ) { + throw new AuthRequiredError( + 'Must be an admin to takedown or label content', + ) + } + validateLabels([...(createLabelVals ?? []), ...(negateLabelVals ?? [])]) const moderationAction = await db.transaction(async (dbTxn) => { diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index ee732671a17..c4c41f1177f 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -12,6 +12,7 @@ const BASIC = 'Basic ' export type ServerAuthOpts = { jwtSecret: string adminPass: string + moderatorPass?: string } // @TODO sync-up with current method names, consider backwards compat. @@ -32,10 +33,12 @@ export type RefreshToken = AuthToken & { jti: string } export class ServerAuth { private _secret: string private _adminPass: string + private _moderatorPass?: string constructor(opts: ServerAuthOpts) { this._secret = opts.jwtSecret this._adminPass = opts.adminPass + this._moderatorPass = opts.moderatorPass } createAccessToken(opts: { @@ -107,13 +110,18 @@ export class ServerAuth { return authorized !== null && authorized.did === did } - verifyAdmin(req: express.Request): boolean { + verifyAdmin(req: express.Request) { const parsed = parseBasicAuth(req.headers.authorization || '') - if (!parsed) return false + if (!parsed) { + return { admin: false, moderator: false } + } const { username, password } = parsed - if (username !== 'admin') return false - if (password !== this._adminPass) return false - return true + if (username !== 'admin') { + return { admin: false, moderator: false } + } + const admin = password === this._adminPass + const moderator = admin || password === this._moderatorPass + return { admin, moderator } } getToken(req: express.Request) { @@ -226,11 +234,21 @@ export const refreshVerifier = export const adminVerifier = (auth: ServerAuth) => async (ctx: { req: express.Request; res: express.Response }) => { - const admin = auth.verifyAdmin(ctx.req) - if (!admin) { + const credentials = auth.verifyAdmin(ctx.req) + if (!credentials.admin) { + throw new AuthRequiredError() + } + return { credentials } + } + +export const moderatorVerifier = + (auth: ServerAuth) => + async (ctx: { req: express.Request; res: express.Response }) => { + const credentials = auth.verifyAdmin(ctx.req) + if (!credentials.moderator) { throw new AuthRequiredError() } - return { credentials: { admin } } + return { credentials } } export const getRefreshTokenId = () => { diff --git a/packages/pds/src/config.ts b/packages/pds/src/config.ts index c65db648ccb..63b44212e9a 100644 --- a/packages/pds/src/config.ts +++ b/packages/pds/src/config.ts @@ -22,6 +22,7 @@ export interface ServerConfigValues { serverDid: string recoveryKey: string adminPassword: string + moderatorPassword?: string inviteRequired: boolean userInviteInterval: number | null @@ -90,6 +91,7 @@ export class ServerConfig { } const adminPassword = process.env.ADMIN_PASSWORD || 'admin' + const moderatorPassword = process.env.MODERATOR_PASSWORD || undefined const inviteRequired = process.env.INVITE_REQUIRED === 'true' ? true : false const userInviteInterval = parseIntWithFallback( @@ -160,6 +162,7 @@ export class ServerConfig { didPlcUrl, serverDid, adminPassword, + moderatorPassword, inviteRequired, userInviteInterval, privacyPolicyUrl, @@ -257,6 +260,10 @@ export class ServerConfig { return this.cfg.adminPassword } + get moderatorPassword() { + return this.cfg.moderatorPassword + } + get inviteRequired() { return this.cfg.inviteRequired } diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index bf47069627a..483f52072d3 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -72,6 +72,10 @@ export class AppContext { return auth.adminVerifier(this.auth) } + get moderatorVerifier() { + return auth.moderatorVerifier(this.auth) + } + get imgUriBuilder(): ImageUriBuilder { return this.opts.imgUriBuilder } diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 7528641b32b..31368194938 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -70,6 +70,7 @@ export class PDS { const auth = new ServerAuth({ jwtSecret: config.jwtSecret, adminPass: config.adminPassword, + moderatorPass: config.moderatorPassword, }) const messageDispatcher = new MessageDispatcher() diff --git a/packages/pds/tests/_util.ts b/packages/pds/tests/_util.ts index 3c6b10bfe73..7bf236fb83b 100644 --- a/packages/pds/tests/_util.ts +++ b/packages/pds/tests/_util.ts @@ -16,6 +16,7 @@ import { HOUR } from '@atproto/common' import { lexToJson } from '@atproto/lexicon' const ADMIN_PASSWORD = 'admin-pass' +const MODERATOR_PASSWORD = 'moderator-pass' export type CloseFn = () => Promise export type TestServerInfo = { @@ -77,6 +78,7 @@ export const runTestServer = async ( serverDid, recoveryKey, adminPassword: ADMIN_PASSWORD, + moderatorPassword: MODERATOR_PASSWORD, inviteRequired: false, userInviteInterval: null, didPlcUrl: plcUrl, @@ -138,10 +140,18 @@ export const runTestServer = async ( } export const adminAuth = () => { + return basicAuth('admin', ADMIN_PASSWORD) +} + +export const moderatorAuth = () => { + return basicAuth('admin', MODERATOR_PASSWORD) +} + +const basicAuth = (username: string, password: string) => { return ( 'Basic ' + uint8arrays.toString( - uint8arrays.fromString('admin:' + ADMIN_PASSWORD, 'utf8'), + uint8arrays.fromString(`${username}:${password}`, 'utf8'), 'base64pad', ) ) diff --git a/packages/pds/tests/account.test.ts b/packages/pds/tests/account.test.ts index 118396f315a..68c6cfa03b1 100644 --- a/packages/pds/tests/account.test.ts +++ b/packages/pds/tests/account.test.ts @@ -198,6 +198,20 @@ describe('account', () => { expect(accnt2?.email).toBe(email) }) + it('disallows non-admin moderators to perform email updates', async () => { + const attemptUpdate = agent.api.com.atproto.admin.updateAccountEmail( + { + account: handle, + email: 'new@email.com', + }, + { + encoding: 'application/json', + headers: { authorization: util.moderatorAuth() }, + }, + ) + await expect(attemptUpdate).rejects.toThrow('Authentication Required') + }) + it('disallows duplicate email addresses and handles', async () => { const inviteCode = await createInviteCode(agent, 2) const email = 'bob@test.com' diff --git a/packages/pds/tests/handles.test.ts b/packages/pds/tests/handles.test.ts index b9350fb7535..e95d9e029ba 100644 --- a/packages/pds/tests/handles.test.ts +++ b/packages/pds/tests/handles.test.ts @@ -4,6 +4,7 @@ import { SeedClient } from './seeds/client' import basicSeed from './seeds/basic' import * as util from './_util' import { AppContext } from '../src' +import { moderatorAuth } from './_util' // outside of suite so they can be used in mock let alice: string @@ -296,5 +297,16 @@ describe('handles', () => { handle: 'bob-alt.test', }) await expect(attempt2).rejects.toThrow('Authentication Required') + const attempt3 = agent.api.com.atproto.admin.updateAccountHandle( + { + did: bob, + handle: 'bob-alt.test', + }, + { + headers: { authorization: moderatorAuth() }, + encoding: 'application/json', + }, + ) + await expect(attempt3).rejects.toThrow('Authentication Required') }) }) diff --git a/packages/pds/tests/moderation.test.ts b/packages/pds/tests/moderation.test.ts index 0659c84d159..0859e151336 100644 --- a/packages/pds/tests/moderation.test.ts +++ b/packages/pds/tests/moderation.test.ts @@ -4,6 +4,7 @@ import { adminAuth, CloseFn, forSnapshot, + moderatorAuth, runTestServer, TestServerInfo, } from './_util' @@ -383,7 +384,7 @@ describe('moderation', () => { }, { encoding: 'application/json', - headers: { authorization: adminAuth() }, + headers: { authorization: moderatorAuth() }, // As moderator }, ) expect(action1).toEqual( @@ -832,6 +833,50 @@ describe('moderation', () => { await expect(getRepoLabels(sc.dids.bob)).resolves.toEqual(['kittens']) }) + it('does not allow non-admin moderators to label.', async () => { + const attemptLabel = agent.api.com.atproto.admin.takeModerationAction( + { + action: ACKNOWLEDGE, + createdBy: 'did:example:moderator', + reason: 'Y', + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + }, + negateLabelVals: ['a'], + createLabelVals: ['b', 'c'], + }, + { + encoding: 'application/json', + headers: { authorization: moderatorAuth() }, + }, + ) + await expect(attemptLabel).rejects.toThrow( + 'Must be an admin to takedown or label content', + ) + }) + + it('does not allow non-admin moderators to takedown.', async () => { + const attemptTakedown = agent.api.com.atproto.admin.takeModerationAction( + { + action: TAKEDOWN, + createdBy: 'did:example:moderator', + reason: 'Y', + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + }, + }, + { + encoding: 'application/json', + headers: { authorization: moderatorAuth() }, + }, + ) + await expect(attemptTakedown).rejects.toThrow( + 'Must be an admin to takedown or label content', + ) + }) + async function actionWithLabels( opts: Partial & { subject: ComAtprotoAdminTakeModerationAction.InputSchema['subject'] diff --git a/packages/pds/tests/views/admin/invites.test.ts b/packages/pds/tests/views/admin/invites.test.ts index 3c2712d2736..db39d7f350a 100644 --- a/packages/pds/tests/views/admin/invites.test.ts +++ b/packages/pds/tests/views/admin/invites.test.ts @@ -1,5 +1,5 @@ import AtpAgent from '@atproto/api' -import { runTestServer, CloseFn, adminAuth } from '../../_util' +import { runTestServer, CloseFn, adminAuth, moderatorAuth } from '../../_util' import { randomStr } from '@atproto/crypto' describe('pds admin invite views', () => { @@ -174,4 +174,29 @@ describe('pds admin invite views', () => { expect(aliceView.data.invitedBy?.uses.length).toBe(2) expect(aliceView.data.invites?.length).toBe(6) }) + + it('does not allow non-admin moderators to disable invites.', async () => { + const attemptDisableInvites = + agent.api.com.atproto.admin.disableInviteCodes( + { codes: ['x'], accounts: [alice] }, + { + encoding: 'application/json', + headers: { authorization: moderatorAuth() }, + }, + ) + await expect(attemptDisableInvites).rejects.toThrow( + 'Authentication Required', + ) + }) + + it('does not allow non-admin moderators to create invites.', async () => { + const attemptCreateInvite = agent.api.com.atproto.server.createInviteCode( + { useCount: 5, forAccount: alice }, + { + encoding: 'application/json', + headers: { authorization: moderatorAuth() }, + }, + ) + await expect(attemptCreateInvite).rejects.toThrow('Authentication Required') + }) })