Skip to content

Commit

Permalink
Add administrative triage role, update moderator role (bluesky-social…
Browse files Browse the repository at this point in the history
…#1216)

* setup triage user on pds, update moderator username (invalidates old token)

* initial pass on triage access on pds, limit access to email addrs

* apply moderator vs triage rules on taking and reversing mod actions for pds

* update pds tests for triage auth role

* setup moderator and triage roles on bsky appview

* apply mod and triage access rules to bsky admin endpoints

* reframe admin auth as role-based auth, tidy auth apis

* tidy

* build

* revert change to basic auth username for role-based auth
  • Loading branch information
devinivy authored Jul 6, 2023
1 parent 60e8284 commit 3ea892b
Show file tree
Hide file tree
Showing 48 changed files with 602 additions and 279 deletions.
1 change: 1 addition & 0 deletions .github/workflows/build-and-push-pds-aws.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ on:
push:
branches:
- main
- auth-triage-role
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
Expand Down
65 changes: 0 additions & 65 deletions packages/bsky/src/api/auth.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { adminVerifier } from '../../../auth'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationAction({
auth: adminVerifier(ctx.cfg.adminPassword),
auth: ctx.roleVerifier,
handler: async ({ params }) => {
const { db, services } = ctx
const { id } = params
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { adminVerifier } from '../../../auth'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationActions({
auth: adminVerifier(ctx.cfg.adminPassword),
auth: ctx.roleVerifier,
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
@@ -1,10 +1,9 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { adminVerifier } from '../../../auth'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationReport({
auth: adminVerifier(ctx.cfg.adminPassword),
auth: ctx.roleVerifier,
handler: async ({ params }) => {
const { db, services } = ctx
const { id } = params
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { adminVerifier } from '../../../auth'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationReports({
auth: adminVerifier(ctx.cfg.adminPassword),
auth: ctx.roleVerifier,
handler: async ({ params }) => {
const { db, services } = ctx
const {
Expand Down
3 changes: 1 addition & 2 deletions packages/bsky/src/api/com/atproto/admin/getRecord.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { adminVerifier } from '../../../auth'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getRecord({
auth: adminVerifier(ctx.cfg.adminPassword),
auth: ctx.roleVerifier,
handler: async ({ params }) => {
const { db, services } = ctx
const { uri, cid } = params
Expand Down
3 changes: 1 addition & 2 deletions packages/bsky/src/api/com/atproto/admin/getRepo.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { adminVerifier } from '../../../auth'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getRepo({
auth: adminVerifier(ctx.cfg.adminPassword),
auth: ctx.roleVerifier,
handler: async ({ params }) => {
const { db, services } = ctx
const { did } = params
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { adminVerifier } from '../../../auth'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.resolveModerationReports({
auth: adminVerifier(ctx.cfg.adminPassword),
auth: ctx.roleVerifier,
handler: async ({ input }) => {
const { db, services } = ctx
const moderationService = services.moderation(db)
Expand Down
36 changes: 31 additions & 5 deletions packages/bsky/src/api/com/atproto/admin/reverseModerationAction.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { AtUri } from '@atproto/uri'
import { InvalidRequestError } from '@atproto/xrpc-server'
import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server'
import {
ACKNOWLEDGE,
ESCALATE,
TAKEDOWN,
} from '../../../../lexicon/types/com/atproto/admin/defs'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { TAKEDOWN } from '../../../../lexicon/types/com/atproto/admin/defs'
import { adminVerifier } from '../../../auth'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.reverseModerationAction({
auth: adminVerifier(ctx.cfg.adminPassword),
handler: async ({ input }) => {
auth: ctx.roleVerifier,
handler: async ({ input, auth }) => {
const access = auth.credentials
const { db, services } = ctx
const moderationService = services.moderation(db)
const { id, createdBy, reason } = input.body
Expand All @@ -28,6 +32,28 @@ export default function (server: Server, ctx: AppContext) {
)
}

// apply access rules

// if less than moderator access then can only reverse ack and escalation actions
if (
!access.moderator &&
![ACKNOWLEDGE, ESCALATE].includes(existing.action)
) {
throw new AuthRequiredError(
'Must be a full moderator to reverse this type of action',
)
}
// if less than admin access then can reverse takedown on an account
if (
!access.admin &&
existing.action === TAKEDOWN &&
existing.subjectType === 'com.atproto.admin.defs#repoRef'
) {
throw new AuthRequiredError(
'Must be an admin to reverse an account takedown',
)
}

const result = await moderationTxn.logReverseAction({
id,
createdAt: now,
Expand Down
3 changes: 1 addition & 2 deletions packages/bsky/src/api/com/atproto/admin/searchRepos.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { adminVerifier } from '../../../auth'
import { paginate } from '../../../../db/pagination'
import { ListKeyset } from '../../../../services/actor'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.searchRepos({
auth: adminVerifier(ctx.cfg.adminPassword),
auth: ctx.roleVerifier,
handler: async ({ params }) => {
const { db, services } = ctx
const moderationService = services.moderation(db)
Expand Down
36 changes: 31 additions & 5 deletions packages/bsky/src/api/com/atproto/admin/takeModerationAction.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { CID } from 'multiformats/cid'
import { AtUri } from '@atproto/uri'
import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { TAKEDOWN } from '../../../../lexicon/types/com/atproto/admin/defs'
import {
ACKNOWLEDGE,
ESCALATE,
TAKEDOWN,
} from '../../../../lexicon/types/com/atproto/admin/defs'
import { getSubject, getAction } from '../moderation/util'
import { InvalidRequestError } from '@atproto/xrpc-server'
import { adminVerifier } from '../../../auth'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.takeModerationAction({
auth: adminVerifier(ctx.cfg.adminPassword),
handler: async ({ input }) => {
auth: ctx.roleVerifier,
handler: async ({ input, auth }) => {
const access = auth.credentials
const { db, services } = ctx
const moderationService = services.moderation(db)
const {
Expand All @@ -23,6 +27,28 @@ export default function (server: Server, ctx: AppContext) {
subjectBlobCids,
} = input.body

// apply access rules

// if less than admin access then can not takedown an account
if (!access.admin && action === TAKEDOWN && 'did' in subject) {
throw new AuthRequiredError(
'Must be an admin to perform an account takedown',
)
}
// if less than moderator access then can only take ack and escalation actions
if (!access.moderator && ![ACKNOWLEDGE, ESCALATE].includes(action)) {
throw new AuthRequiredError(
'Must be a full moderator to take this type of action',
)
}
// if less than moderator access then can not apply labels
if (
!access.moderator &&
(createLabelVals?.length || negateLabelVals?.length)
) {
throw new AuthRequiredError('Must be a full moderator to label content')
}

validateLabels([...(createLabelVals ?? []), ...(negateLabelVals ?? [])])

const moderationAction = await db.transaction(async (dbTxn) => {
Expand Down
52 changes: 50 additions & 2 deletions packages/bsky/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import express from 'express'
import * as uint8arrays from 'uint8arrays'
import { AuthRequiredError, verifyJwt } from '@atproto/xrpc-server'
import { IdResolver } from '@atproto/identity'
import { ServerConfig } from './config'

const BASIC = 'Basic '
const BEARER = 'Bearer '

export const authVerifier =
(idResolver: IdResolver, opts: { aud: string | null }) =>
Expand All @@ -25,10 +30,53 @@ export const authOptionalVerifier =
return authVerifier(idResolver, opts)(reqCtx)
}

export const roleVerifier =
(cfg: ServerConfig) =>
async (reqCtx: { req: express.Request; res: express.Response }) => {
const credentials = getRoleCredentials(cfg, reqCtx.req)
if (!credentials.valid) {
throw new AuthRequiredError()
}
return { credentials }
}

export const getRoleCredentials = (cfg: ServerConfig, req: express.Request) => {
const parsed = parseBasicAuth(req.headers.authorization || '')
const { username, password } = parsed ?? {}
if (username === 'admin' && password === cfg.triagePassword) {
return { valid: true, admin: false, moderator: false, triage: true }
}
if (username === 'admin' && password === cfg.moderatorPassword) {
return { valid: true, admin: false, moderator: true, triage: true }
}
if (username === 'admin' && password === cfg.adminPassword) {
return { valid: true, admin: true, moderator: true, triage: true }
}
return { valid: false, admin: false, moderator: false, triage: false }
}

export const parseBasicAuth = (
token: string,
): { username: string; password: string } | null => {
if (!token.startsWith(BASIC)) return null
const b64 = token.slice(BASIC.length)
let parsed: string[]
try {
parsed = uint8arrays
.toString(uint8arrays.fromString(b64, 'base64pad'), 'utf8')
.split(':')
} catch (err) {
return null
}
const [username, password] = parsed
if (!username || !password) return null
return { username, password }
}

export const getJwtStrFromReq = (req: express.Request): string | null => {
const { authorization = '' } = req.headers
if (!authorization.startsWith('Bearer ')) {
if (!authorization.startsWith(BEARER)) {
return null
}
return authorization.replace('Bearer ', '').trim()
return authorization.replace(BEARER, '').trim()
}
14 changes: 14 additions & 0 deletions packages/bsky/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface ServerConfigValues {
labelerDid: string
hiveApiKey?: string
adminPassword: string
moderatorPassword?: string
triagePassword?: string
labelerKeywords: Record<string, string>
indexerConcurrency?: number
}
Expand Down Expand Up @@ -60,6 +62,8 @@ export class ServerConfig {
const dbPostgresSchema = process.env.DB_POSTGRES_SCHEMA
const repoProvider = process.env.REPO_PROVIDER // E.g. ws://abc.com:4000
const adminPassword = process.env.ADMIN_PASSWORD || 'admin'
const moderatorPassword = process.env.MODERATOR_PASSWORD || undefined
const triagePassword = process.env.TRIAGE_PASSWORD || undefined
const labelerDid = process.env.LABELER_DID || 'did:example:labeler'
const hiveApiKey = process.env.HIVE_API_KEY || undefined
const indexerConcurrency = maybeParseInt(process.env.INDEXER_CONCURRENCY)
Expand All @@ -84,6 +88,8 @@ export class ServerConfig {
labelerDid,
hiveApiKey,
adminPassword,
moderatorPassword,
triagePassword,
labelerKeywords,
indexerConcurrency,
...stripUndefineds(overrides ?? {}),
Expand Down Expand Up @@ -187,6 +193,14 @@ export class ServerConfig {
return this.cfg.adminPassword
}

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

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

get indexerConcurrency() {
return this.cfg.indexerConcurrency
}
Expand Down
Loading

0 comments on commit 3ea892b

Please sign in to comment.