Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ozone ACLs #2252

Merged
merged 28 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6a26396
tidy bsky auth
dholms Feb 29, 2024
125f721
hook up new auth verifier
dholms Feb 29, 2024
36da1d9
update auth throughout ozone
dholms Feb 29, 2024
32b3de8
handle mod signing keys
dholms Feb 29, 2024
199b754
fix bad var
dholms Feb 29, 2024
d1d39ff
merge main
dholms Feb 29, 2024
cb53fdc
fix key parsing in pds
dholms Feb 29, 2024
e3bfb17
fix admin auth test
dholms Mar 1, 2024
a642063
rename test
dholms Mar 1, 2024
f7ef546
update did doc id values
dholms Mar 4, 2024
0482a92
null creds string -> `none`
dholms Mar 4, 2024
5df31de
fix fetchLabels auth check
dholms Mar 5, 2024
dd891d4
:sparkles: Add a couple more proxied requests that we use in ozone ui
foysalit Mar 5, 2024
2ca4fee
Add runit to the services/bsky Dockerfile (#2254)
Jacob2161 Feb 29, 2024
6ba5f6c
Improve tag detection (#2260)
estrattonbailey Mar 1, 2024
9b2500e
Version packages (#2261)
github-actions[bot] Mar 1, 2024
c76fd03
:bug: Increment attempt count after each attempt to push ozone event …
foysalit Mar 4, 2024
87f00f2
Ozone delegates email sending to actor's pds (#2272)
devinivy Mar 5, 2024
8341c7a
fix auth verifier method
dholms Mar 5, 2024
11b7af2
merge main
dholms Mar 5, 2024
037f163
better error handling for get account infos
dholms Mar 5, 2024
fc1c40d
fix labeler service id
dholms Mar 5, 2024
5e1c5fd
fix iss on auth headers
dholms Mar 5, 2024
82acea2
fix dev-env ozone did
dholms Mar 5, 2024
4c7db5c
fix tests & another jwt issuer
dholms Mar 5, 2024
81f9d69
ozone: fix ip check
devinivy Mar 5, 2024
7be8445
fix aud check on pds mod service auth
dholms Mar 5, 2024
592518c
tidy
dholms Mar 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
dholms marked this conversation as resolved.
Show resolved Hide resolved
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))
Comment on lines +263 to +265
Copy link
Collaborator

@devinivy devinivy Mar 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making sure I understand correctly—this means the mod service can view takedowns either:

  • as a mod service, using the mod service signing key (also used for e.g. signing labels).
  • as an actor, using their repo signing key.

Does that sound right? If so, when does the latter come into play?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup that does sound right - maybe it could be communicated better.

But the latter comes into play on some of the view routes where we still want to show taken down content - getProfile for instance

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use a separate authVerifier there like optionalStandardOrModService but I thought it was a bit clearer to just use optionalStandard and then do a follow on check to see if it came from a trusted did

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
3 changes: 3 additions & 0 deletions packages/dev-env/src/ozone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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
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 @@ -11,7 +11,7 @@ import { ModerationLangService } from '../../mod-service/lang'

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 @@ -29,15 +29,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 @@ -46,7 +46,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
2 changes: 1 addition & 1 deletion packages/ozone/src/api/admin/getModerationEvent.ts
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.getModerationEvent({
auth: ctx.roleVerifier,
auth: ctx.authVerifier.modOrRole,
handler: async ({ params }) => {
const { id } = params
const db = ctx.db
Expand Down
4 changes: 2 additions & 2 deletions packages/ozone/src/api/admin/getRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AtUri } from '@atproto/syntax'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getRecord({
auth: ctx.roleVerifier,
auth: ctx.authVerifier.modOrRole,
handler: async ({ params, auth }) => {
const db = ctx.db

Expand All @@ -22,7 +22,7 @@ export default function (server: Server, ctx: AppContext) {
record.repo = addAccountInfoToRepoView(
record.repo,
accountInfo,
auth.credentials.moderator,
auth.credentials.isModerator,
)

return {
Expand Down
4 changes: 2 additions & 2 deletions packages/ozone/src/api/admin/getRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { addAccountInfoToRepoViewDetail, getPdsAccountInfo } from './util'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getRepo({
auth: ctx.roleVerifier,
auth: ctx.authVerifier.modOrRole,
handler: async ({ params, auth }) => {
const { did } = params
const db = ctx.db
Expand All @@ -20,7 +20,7 @@ export default function (server: Server, ctx: AppContext) {
const repo = addAccountInfoToRepoViewDetail(
partialRepo,
accountInfo,
auth.credentials.moderator,
auth.credentials.isModerator,
)
return {
encoding: 'application/json',
Expand Down
4 changes: 2 additions & 2 deletions packages/ozone/src/api/admin/listCommunicationTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import AppContext from '../../context'

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

if (!access.moderator) {
if (!access.isModerator) {
throw new AuthRequiredError(
'Must be a full moderator to view list of communication template',
)
Expand Down
2 changes: 1 addition & 1 deletion packages/ozone/src/api/admin/queryModerationEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getEventType } from '../moderation/util'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.queryModerationEvents({
auth: ctx.roleVerifier,
auth: ctx.authVerifier.modOrRole,
handler: async ({ params }) => {
const {
subject,
Expand Down
2 changes: 1 addition & 1 deletion packages/ozone/src/api/admin/queryModerationStatuses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getReviewState } from '../moderation/util'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.queryModerationStatuses({
auth: ctx.roleVerifier,
auth: ctx.authVerifier.modOrRole,
handler: async ({ params }) => {
const {
subject,
Expand Down
2 changes: 1 addition & 1 deletion packages/ozone/src/api/admin/searchRepos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mapDefined } from '@atproto/common'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.searchRepos({
auth: ctx.roleVerifier,
auth: ctx.authVerifier.modOrRole,
handler: async ({ params }) => {
const modService = ctx.modService(ctx.db)

Expand Down
Loading
Loading