Skip to content

Commit

Permalink
PDS moderator credentials (#863)
Browse files Browse the repository at this point in the history
* Setup config and auth verifiers for moderators

* Enforce admin vs. moderator access on PDS admin/server endpoints

* Tidy
  • Loading branch information
devinivy authored Apr 22, 2023
1 parent 5b4f506 commit a832c54
Show file tree
Hide file tree
Showing 19 changed files with 170 additions and 23 deletions.
2 changes: 1 addition & 1 deletion packages/pds/src/api/com/atproto/admin/getInviteCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/pds/src/api/com/atproto/admin/getRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/pds/src/api/com/atproto/admin/getRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/pds/src/api/com/atproto/admin/searchRepos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 14 additions & 3 deletions packages/pds/src/api/com/atproto/admin/takeModerationAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) => {
Expand Down
34 changes: 26 additions & 8 deletions packages/pds/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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: {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/pds/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ServerConfigValues {
serverDid: string
recoveryKey: string
adminPassword: string
moderatorPassword?: string

inviteRequired: boolean
userInviteInterval: number | null
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -160,6 +162,7 @@ export class ServerConfig {
didPlcUrl,
serverDid,
adminPassword,
moderatorPassword,
inviteRequired,
userInviteInterval,
privacyPolicyUrl,
Expand Down Expand Up @@ -257,6 +260,10 @@ export class ServerConfig {
return this.cfg.adminPassword
}

get moderatorPassword() {
return this.cfg.moderatorPassword
}

get inviteRequired() {
return this.cfg.inviteRequired
}
Expand Down
4 changes: 4 additions & 0 deletions packages/pds/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions packages/pds/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class PDS {
const auth = new ServerAuth({
jwtSecret: config.jwtSecret,
adminPass: config.adminPassword,
moderatorPass: config.moderatorPassword,
})

const messageDispatcher = new MessageDispatcher()
Expand Down
12 changes: 11 additions & 1 deletion packages/pds/tests/_util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
export type TestServerInfo = {
Expand Down Expand Up @@ -77,6 +78,7 @@ export const runTestServer = async (
serverDid,
recoveryKey,
adminPassword: ADMIN_PASSWORD,
moderatorPassword: MODERATOR_PASSWORD,
inviteRequired: false,
userInviteInterval: null,
didPlcUrl: plcUrl,
Expand Down Expand Up @@ -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',
)
)
Expand Down
14 changes: 14 additions & 0 deletions packages/pds/tests/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]',
},
{
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 = '[email protected]'
Expand Down
12 changes: 12 additions & 0 deletions packages/pds/tests/handles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
})
})
47 changes: 46 additions & 1 deletion packages/pds/tests/moderation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
adminAuth,
CloseFn,
forSnapshot,
moderatorAuth,
runTestServer,
TestServerInfo,
} from './_util'
Expand Down Expand Up @@ -383,7 +384,7 @@ describe('moderation', () => {
},
{
encoding: 'application/json',
headers: { authorization: adminAuth() },
headers: { authorization: moderatorAuth() }, // As moderator
},
)
expect(action1).toEqual(
Expand Down Expand Up @@ -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<ComAtprotoAdminTakeModerationAction.InputSchema> & {
subject: ComAtprotoAdminTakeModerationAction.InputSchema['subject']
Expand Down
Loading

0 comments on commit a832c54

Please sign in to comment.