Skip to content

Commit

Permalink
Ozone ACLs (#2252)
Browse files Browse the repository at this point in the history
* tidy bsky auth

* hook up new auth verifier

* update auth throughout ozone

* handle mod signing keys

* fix bad var

* fix key parsing in pds

* fix admin auth test

* rename test

* update did doc id values

* null creds string -> `none`

* fix fetchLabels auth check

* ✨ Add a couple more proxied requests that we use in ozone ui

* Add runit to the services/bsky Dockerfile (#2254)

add runit to the services/bsky Dockerfile

* Improve tag detection (#2260)

* Allow tags to lead with and contain only numbers

* Break tags on other whitespace characters

* Export regexes from rich text detection

* Add test

* Add test

* Disallow number-only tags

* Avoid combining enclosing screen chars

* Allow full-width number sign

* Clarify tests

* Fix punctuation edge case

* Reorder

* Simplify, add another test

* Another test, comment

* Version packages (#2261)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* 🐛 Increment attempt count after each attempt to push ozone event (#2239)

* Ozone delegates email sending to actor's pds (#2272)

* ozone delegates email sending to user's pds

* lexicon: add content field to mod email event

* test email sending via mod event

* fix auth verifier method

* better error handling for get account infos

* fix labeler service id

* fix iss on auth headers

* fix dev-env ozone did

* fix tests & another jwt issuer

* ozone: fix ip check

* fix aud check on pds mod service auth

* tidy

---------

Co-authored-by: Foysal Ahamed <[email protected]>
Co-authored-by: Jake Gold <[email protected]>
Co-authored-by: Eric Bailey <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: devin ivy <[email protected]>
  • Loading branch information
7 people authored Mar 6, 2024
1 parent c7e6ef0 commit 582109c
Show file tree
Hide file tree
Showing 39 changed files with 592 additions and 300 deletions.
2 changes: 1 addition & 1 deletion packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { INVALID_HANDLE } from '@atproto/syntax'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getAccountInfos({
auth: ctx.authVerifier.roleOrAdminService,
auth: ctx.authVerifier.roleOrModService,
handler: async ({ params }) => {
const { dids } = params
const actors = await ctx.hydrator.actor.getActors(dids, true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSub

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getSubjectStatus({
auth: ctx.authVerifier.roleOrAdminService,
auth: ctx.authVerifier.roleOrModService,
handler: async ({ params }) => {
const { did, uri, blob } = params

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/rep

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.updateSubjectStatus({
auth: ctx.authVerifier.roleOrAdminService,
auth: ctx.authVerifier.roleOrModService,
handler: async ({ input, auth }) => {
const { canPerformTakedown } = ctx.authVerifier.parseCreds(auth)
if (!canPerformTakedown) {
Expand Down
63 changes: 42 additions & 21 deletions packages/bsky/src/auth-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export enum RoleStatus {

type NullOutput = {
credentials: {
type: 'null'
type: 'none'
iss: null
}
}
Expand All @@ -45,28 +45,28 @@ type RoleOutput = {
}
}

type AdminServiceOutput = {
type ModServiceOutput = {
credentials: {
type: 'admin_service'
type: 'mod_service'
aud: string
iss: string
}
}

export type AuthVerifierOpts = {
ownDid: string
adminDid: string
modServiceDid: string
adminPasses: string[]
}

export class AuthVerifier {
public ownDid: string
public adminDid: string
public modServiceDid: string
private adminPasses: Set<string>

constructor(public dataplane: DataPlaneClient, opts: AuthVerifierOpts) {
this.ownDid = opts.ownDid
this.adminDid = opts.adminDid
this.modServiceDid = opts.modServiceDid
this.adminPasses = new Set(opts.adminPasses)
}

Expand All @@ -83,13 +83,21 @@ export class AuthVerifier {
if (!this.parseRoleCreds(ctx.req).admin) {
throw new AuthRequiredError('bad credentials')
}
return { credentials: { type: 'standard', iss, aud } }
return {
credentials: { type: 'standard', iss, aud },
}
}
const { iss, aud } = await this.verifyServiceJwt(ctx, {
aud: this.ownDid,
iss: null,
})
return { credentials: { type: 'standard', iss, aud } }
return {
credentials: {
type: 'standard',
iss,
aud,
},
}
}

standardOptional = async (
Expand Down Expand Up @@ -159,19 +167,19 @@ export class AuthVerifier {
}
}

adminService = async (reqCtx: ReqCtx): Promise<AdminServiceOutput> => {
modService = async (reqCtx: ReqCtx): Promise<ModServiceOutput> => {
const { iss, aud } = await this.verifyServiceJwt(reqCtx, {
aud: this.ownDid,
iss: [this.adminDid],
iss: [this.modServiceDid, `${this.modServiceDid}#atproto_labeler`],
})
return { credentials: { type: 'admin_service', aud, iss } }
return { credentials: { type: 'mod_service', aud, iss } }
}

roleOrAdminService = async (
roleOrModService = async (
reqCtx: ReqCtx,
): Promise<RoleOutput | AdminServiceOutput> => {
): Promise<RoleOutput | ModServiceOutput> => {
if (isBearerToken(reqCtx.req)) {
return this.adminService(reqCtx)
return this.modService(reqCtx)
} else {
return this.role(reqCtx)
}
Expand All @@ -195,12 +203,15 @@ export class AuthVerifier {
opts: { aud: string | null; iss: string[] | null },
) {
const getSigningKey = async (
did: string,
iss: string,
_forceRefresh: boolean, // @TODO consider propagating to dataplane
): Promise<string> => {
if (opts.iss !== null && !opts.iss.includes(did)) {
if (opts.iss !== null && !opts.iss.includes(iss)) {
throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss')
}
const [did, serviceId] = iss.split('#')
const keyId =
serviceId === 'atproto_labeler' ? 'atproto_label' : 'atproto'
let identity: GetIdentityByDidResponse
try {
identity = await this.dataplane.getIdentityByDid({ did })
Expand All @@ -211,7 +222,7 @@ export class AuthVerifier {
throw err
}
const keys = unpackIdentityKeys(identity.keys)
const didKey = getKeyAsDidKey(keys, { id: 'atproto' })
const didKey = getKeyAsDidKey(keys, { id: keyId })
if (!didKey) {
throw new AuthRequiredError('missing or bad key')
}
Expand All @@ -226,26 +237,36 @@ export class AuthVerifier {
return { iss: payload.iss, aud: payload.aud }
}

isModService(iss: string): boolean {
return [
this.modServiceDid,
`${this.modServiceDid}#atproto_labeler`,
].includes(iss)
}

nullCreds(): NullOutput {
return {
credentials: {
type: 'null',
type: 'none',
iss: null,
},
}
}

parseCreds(
creds: StandardOutput | RoleOutput | AdminServiceOutput | NullOutput,
creds: StandardOutput | RoleOutput | ModServiceOutput | NullOutput,
) {
const viewer =
creds.credentials.type === 'standard' ? creds.credentials.iss : null
const canViewTakedowns =
(creds.credentials.type === 'role' && creds.credentials.admin) ||
creds.credentials.type === 'admin_service'
creds.credentials.type === 'mod_service' ||
(creds.credentials.type === 'standard' &&
this.isModService(creds.credentials.iss))
const canPerformTakedown =
(creds.credentials.type === 'role' && creds.credentials.admin) ||
creds.credentials.type === 'admin_service'
creds.credentials.type === 'mod_service'

return {
viewer,
canViewTakedowns,
Expand Down
2 changes: 1 addition & 1 deletion packages/bsky/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export class BskyAppView {

const authVerifier = new AuthVerifier(dataplane, {
ownDid: config.serverDid,
adminDid: config.modServiceDid,
modServiceDid: config.modServiceDid,
adminPasses: config.adminPasswords,
})

Expand Down
10 changes: 9 additions & 1 deletion packages/common-web/src/did-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export const getHandle = (doc: DidDocument): string | undefined => {
// @NOTE we parse to type/publicKeyMultibase to avoid the dependency on @atproto/crypto
export const getSigningKey = (
doc: DidDocument,
): { type: string; publicKeyMultibase: string } | undefined => {
return getVerificationMaterial(doc, 'atproto')
}

export const getVerificationMaterial = (
doc: DidDocument,
keyId: string,
): { type: string; publicKeyMultibase: string } | undefined => {
const did = getDid(doc)
let keys = doc.verificationMethod
Expand All @@ -36,14 +43,15 @@ export const getSigningKey = (
keys = [keys]
}
const found = keys.find(
(key) => key.id === '#atproto' || key.id === `${did}#atproto`,
(key) => key.id === `#${keyId}` || key.id === `${did}#${keyId}`,
)
if (!found?.publicKeyMultibase) return undefined
return {
type: found.type,
publicKeyMultibase: found.publicKeyMultibase,
}
}

export const getSigningDidKey = (doc: DidDocument): string | undefined => {
const parsed = getSigningKey(doc)
if (!parsed) return
Expand Down
11 changes: 2 additions & 9 deletions packages/dev-env/src/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ import * as uint8arrays from 'uint8arrays'
import getPort from 'get-port'
import { wait } from '@atproto/common-web'
import { createServiceJwt } from '@atproto/xrpc-server'
import { Client as PlcClient } from '@did-plc/lib'
import { TestServerParams } from './types'
import { TestPlc } from './plc'
import { TestPds } from './pds'
import { TestBsky } from './bsky'
import { TestOzone } from './ozone'
import { TestOzone, createOzoneDid } from './ozone'
import { mockNetworkUtilities } from './util'
import { TestNetworkNoAppView } from './network-no-appview'
import { Secp256k1Keypair } from '@atproto/crypto'
Expand Down Expand Up @@ -43,13 +42,7 @@ export class TestNetwork extends TestNetworkNoAppView {
const ozonePort = params.ozone?.port ?? (await getPort())

const ozoneKey = await Secp256k1Keypair.create({ exportable: true })
const ozoneDid = await new PlcClient(plc.url).createDid({
signingKey: ozoneKey.did(),
rotationKeys: [ozoneKey.did()],
handle: 'ozone.test',
pds: `http://pds.invalid`,
signer: ozoneKey,
})
const ozoneDid = await createOzoneDid(plc.url, ozoneKey)

const bsky = await TestBsky.create({
port: bskyPort,
Expand Down
45 changes: 35 additions & 10 deletions packages/dev-env/src/ozone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import getPort from 'get-port'
import * as ui8 from 'uint8arrays'
import * as ozone from '@atproto/ozone'
import { AtpAgent } from '@atproto/api'
import { Secp256k1Keypair } from '@atproto/crypto'
import { Client as PlcClient } from '@did-plc/lib'
import { Keypair, Secp256k1Keypair } from '@atproto/crypto'
import * as plc from '@did-plc/lib'
import { OzoneConfig } from './types'
import { ADMIN_PASSWORD, MOD_PASSWORD, TRIAGE_PASSWORD } from './const'

Expand All @@ -21,18 +21,12 @@ export class TestOzone {
const signingKeyHex = ui8.toString(await serviceKeypair.export(), 'hex')
let serverDid = config.serverDid
if (!serverDid) {
const plcClient = new PlcClient(config.plcUrl)
serverDid = await plcClient.createDid({
signingKey: serviceKeypair.did(),
rotationKeys: [serviceKeypair.did()],
handle: 'ozone.test',
pds: `https://pds.invalid`,
signer: serviceKeypair,
})
serverDid = await createOzoneDid(config.plcUrl, serviceKeypair)
}

const port = config.port || (await getPort())
const url = `http://localhost:${port}`

const env: ozone.OzoneEnvironment = {
devMode: true,
version: '0.0.0',
Expand All @@ -45,6 +39,9 @@ export class TestOzone {
adminPassword: ADMIN_PASSWORD,
moderatorPassword: MOD_PASSWORD,
triagePassword: TRIAGE_PASSWORD,
adminDids: [],
moderatorDids: [],
triageDids: [],
}

// Separate migration db in case migration changes some connection state that we need in the tests, e.g. "alter database ... set ..."
Expand Down Expand Up @@ -113,3 +110,31 @@ export class TestOzone {
await this.server.destroy()
}
}

export const createOzoneDid = async (
plcUrl: string,
keypair: Keypair,
): Promise<string> => {
const plcClient = new plc.Client(plcUrl)
const plcOp = await plc.signOperation(
{
type: 'plc_operation',
alsoKnownAs: [],
rotationKeys: [keypair.did()],
verificationMethods: {
atproto_label: keypair.did(),
},
services: {
atproto_labeler: {
type: 'AtprotoLabeler',
endpoint: 'https://ozone.public.url',
},
},
prev: null,
},
keypair,
)
const did = await plc.didForCreateOp(plcOp)
await plcClient.sendOperation(did, plcOp)
return did
}
4 changes: 2 additions & 2 deletions packages/ozone/src/api/admin/createCommunicationTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import AppContext from '../../context'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.createCommunicationTemplate({
auth: ctx.roleVerifier,
auth: ctx.authVerifier.modOrRole,
handler: async ({ input, auth }) => {
const access = auth.credentials
const db = ctx.db
const { createdBy, ...template } = input.body

if (!access.admin) {
if (!access.isAdmin) {
throw new AuthRequiredError(
'Must be an admin to create a communication template',
)
Expand Down
4 changes: 2 additions & 2 deletions packages/ozone/src/api/admin/deleteCommunicationTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import AppContext from '../../context'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.deleteCommunicationTemplate({
auth: ctx.roleVerifier,
auth: ctx.authVerifier.modOrRole,
handler: async ({ input, auth }) => {
const access = auth.credentials
const db = ctx.db
const { id } = input.body

if (!access.admin) {
if (!access.isAdmin) {
throw new AuthRequiredError(
'Must be an admin to delete a communication template',
)
Expand Down
8 changes: 4 additions & 4 deletions packages/ozone/src/api/admin/emitModerationEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { retryHttp } from '../../util'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.emitModerationEvent({
auth: ctx.roleVerifier,
auth: ctx.authVerifier.modOrRole,
handler: async ({ input, auth }) => {
const access = auth.credentials
const db = ctx.db
Expand All @@ -31,15 +31,15 @@ export default function (server: Server, ctx: AppContext) {

// if less than moderator access then can only take ack and escalation actions
if (isTakedownEvent || isReverseTakedownEvent) {
if (!access.moderator) {
if (!access.isModerator) {
throw new AuthRequiredError(
'Must be a full moderator to take this type of action',
)
}

// Non admins should not be able to take down feed generators
if (
!access.admin &&
!access.isAdmin &&
subject.recordPath?.includes('app.bsky.feed.generator/')
) {
throw new AuthRequiredError(
Expand All @@ -48,7 +48,7 @@ export default function (server: Server, ctx: AppContext) {
}
}
// if less than moderator access then can not apply labels
if (!access.moderator && isLabelEvent) {
if (!access.isModerator && isLabelEvent) {
throw new AuthRequiredError('Must be a full moderator to label content')
}

Expand Down
Loading

0 comments on commit 582109c

Please sign in to comment.