Skip to content

Commit

Permalink
feat: begin implementing some gql access control, annotated w/ comments
Browse files Browse the repository at this point in the history
  • Loading branch information
BlairCurrey committed Oct 9, 2024
1 parent 32608f0 commit 173b6b2
Show file tree
Hide file tree
Showing 12 changed files with 145 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ post {
auth: none
}

headers {
~x-operator-secret: tXhxCNRWuVOJs5W/CUgjsc+vBnKj0CVdqSb87EXN64E=
}

body:graphql {
mutation CreateOutgoingPayment($input: CreateOutgoingPaymentInput!) {
createOutgoingPayment(input: $input) {
Expand Down Expand Up @@ -46,7 +50,7 @@ body:graphql:vars {
{
"input": {
"walletAddressId": "{{gfranklinWalletAddressId}}",
"quoteId": "{{quoteId}}"
"quoteId": "{{quoteId}}", "tenantId": ""
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ post {
auth: none
}

headers {
~x-operator-secret: {{operatorApiSecret}}
}

body:graphql {
mutation CreateQuote($input: CreateQuoteInput!) {
createQuote(input: $input) {
Expand Down Expand Up @@ -38,7 +42,8 @@ body:graphql:vars {
{
"input": {
"walletAddressId": "{{gfranklinWalletAddressId}}",
"receiver": "{{receiverId}}"
"receiver": "{{receiverId}}",
"tenantId": ""
}
}
}
Expand All @@ -53,7 +58,7 @@ script:pre-request {

script:post-response {
const body = res.getBody();

if (body?.data) {
bru.setEnvVar("quoteId", body.data.createQuote.quote.id);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ post {
auth: none
}

headers {
~x-operator-secret: {{operatorApiSecret}}
}

body:graphql {
mutation CreateReceiver($input: CreateReceiverInput!) {
createReceiver(input: $input) {
Expand Down Expand Up @@ -60,7 +64,7 @@ script:pre-request {

script:post-response {
const body = res.getBody();

if (body?.data) {
bru.setEnvVar("receiverId", body.data.createReceiver.receiver.id);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ post {
auth: none
}

headers {
~x-operator-secret: tXhxCNRWuVOJs5W/CUgjsc+vBnKj0CVdqSb87EXN64E=
}

body:graphql {
query GetOutgoingPayment($id: String!) {
outgoingPayment(id: $id) {
Expand Down
3 changes: 2 additions & 1 deletion bruno/collections/Rafiki/scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ const scripts = {
method: 'post',
headers: {
signature: this.generateBackendApiSignature(postBody),
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'x-operator-secret': bru.getEnvVar('operatorApiSecret')
},
body: JSON.stringify(postBody)
}
Expand Down
27 changes: 25 additions & 2 deletions packages/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export interface AppContextData {
export interface ApolloContext {
container: IocContract<AppServices>
logger: Logger
tenantId: string
isOperator: boolean
}
export type AppContext = Koa.ParameterizedContext<DefaultState, AppContextData>

Expand Down Expand Up @@ -439,15 +441,36 @@ export class App {
await getTenantIdFromOperatorSecret(ctx, this.config)
return next()
} else {
await getTenantIdFromRequestHeaders(ctx, this.config)
// TODO: cache. adds network call to every gql resolver and
// not all resolvers need tenantId/isOperator.
// await getTenantIdFromRequestHeaders(ctx, this.config)

// TODO: remove this and restore getTenantIdFromRequestHeaders.
// This is just a hack to be able to test normal tenant (non-operator)
// case from bruno
const tenantService = await this.container.use('tenantService')
const tenant = await tenantService.getByEmail(
'c9PrimaryTenant@example.com' ??
(await tenantService.getByEmail('hlbPrimaryTenant@example.com'))
)
console.log(tenant)

if (!tenant) {
throw new Error('could not find tenantId')
}

ctx.tenantId = tenant.id
ctx.isOperator = false
return next()
}
})

koa.use(
koaMiddleware(this.apolloServer, {
context: async (): Promise<ApolloContext> => {
context: async ({ ctx }) => {
return {
tenantId: ctx.tenantId,
isOperator: ctx.isOperator,
container: this.container,
logger: await this.container.use('logger')
}
Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/graphql/resolvers/incoming_payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,23 @@ export const cancelIncomingPayment: MutationResolvers<ApolloContext>['cancelInco
'incomingPaymentService'
)

// ACCESS CONTROL CASE: Update/Deletes. Check existing resource's tenantId before mutating.

// If from operator use tenantId given on input, otherwise use the requestor's tenant's id.
// In other words, dont use the operator's tenantId
const tenantId = ctx.isOperator ? args.input.tenantId : ctx.tenantId
const incomingPayment = await incomingPaymentService.get({
id: args.input.id
})
if (!incomingPayment || tenantId !== incomingPayment.tenantId) {
throw new GraphQLError('Unknown incoming payment', {
extensions: {
code: GraphQLErrorCode.NotFound,
id: args.input.id
}
})
}

const incomingPaymentOrError = await incomingPaymentService.cancel(
args.input.id
)
Expand Down
46 changes: 42 additions & 4 deletions packages/backend/src/graphql/resolvers/outgoing_payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,27 @@ export const getOutgoingPayment: QueryResolvers<ApolloContext>['outgoingPayment'
const payment = await outgoingPaymentService.get({
id: args.id
})
if (!payment) {
// ACCESS CONTROL CASE: Gets. No additional query - just compare resource/given tenantId.

// Simple, non-service based access control that should generally work for gets.
// But is it a good pattern? Is the access control happening too late, and too
// embedded in resolver logic?
// Alternatives (with their own, different considarations):
// - util fn or middleware to lookup resource and check access before getting it.
// Maybe uses a new service method like: outgoingPaymentService.canAccess(tenantId, isOperator)
// - Adds extra service call but better seperation of concerns. Does not enforce
// access control in a central way (have to add middleware/call fn for each resolver).
// - Check in service. Either in existing method or new method like:
// outgoingPaymentService.getTenanted(id, tenantId, isOperator)
// - Updating existing enforces access control in more central way, but perhaps too univerals?
// Wont have tenantId/isOperator in all cases (calling from connector, for example).
// Can add new methods instead in those cases but I think this duplicates a lot of code (and tests).
// And is it really enforcing it more centrally if it's still up to the caller to call the
// right method? tenanted vs. non-tenanted? If not it would be simpler to handle in resolver level.

// Operator can always get resource. If not the operator and tenantId's don't match,
// it should present as-if the resouce wasn't found.
if (!payment || (!ctx.isOperator && payment.tenantId !== ctx.tenantId)) {
throw new GraphQLError('payment does not exist', {
extensions: {
code: GraphQLErrorCode.NotFound
Expand Down Expand Up @@ -104,12 +124,30 @@ export const createOutgoingPayment: MutationResolvers<ApolloContext>['createOutg
args,
ctx
): Promise<ResolversTypes['OutgoingPaymentResponse']> => {
if (!ctx.isOperator) {
const walletAddressService = await ctx.container.use(
'walletAddressService'
)
const walletAddress = await walletAddressService.get(
args.input.walletAddressId
)
if (!walletAddress || ctx.tenantId !== walletAddress.tenantId) {
throw new GraphQLError('Unknown wallet address id input', {
extensions: {
code: 400,
walletAddressUrl: args.input.walletAddressId
}
})
}
}

const outgoingPaymentService = await ctx.container.use(
'outgoingPaymentService'
)
const outgoingPaymentOrError = await outgoingPaymentService.create(
args.input
)
const outgoingPaymentOrError = await outgoingPaymentService.create({
...args.input,
tenantId: ctx.tenantId
})
if (isOutgoingPaymentError(outgoingPaymentOrError)) {
throw new GraphQLError(errorToMessage[outgoingPaymentOrError], {
extensions: {
Expand Down
28 changes: 28 additions & 0 deletions packages/backend/src/graphql/resolvers/quote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,35 @@ export const getQuote: QueryResolvers<ApolloContext>['quote'] = async (

export const createQuote: MutationResolvers<ApolloContext>['createQuote'] =
async (parent, args, ctx): Promise<ResolversTypes['QuoteResponse']> => {
// ACCESS CONTROL CASE: Creates. Get associated wallet address tenantId and compare
// to requestor's tenantId before creating.

// If from operator use tenantId in input, otherwise use the tenant id from the requestor.
// In other words, dont use the operator's tenantId - operator must create
// on behalf of non-operator tenant
const tenantId = ctx.isOperator ? args.input.tenantId : ctx.tenantId

// tenantId should match tenantId on wallet address - present as-if WA was not found
const walletAddressService = await ctx.container.use('walletAddressService')
const walletAddress = await walletAddressService.get(
args.input.walletAddressId
)
if (!walletAddress || tenantId !== walletAddress.tenantId) {
throw new GraphQLError('Unknown wallet address id input', {
extensions: {
code: GraphQLErrorCode.BadUserInput,
walletAddressId: args.input.walletAddressId
}
})
}

const quoteService = await ctx.container.use('quoteService')
// Optionally, quoteService.create could implicity add the correct tenantId
// based on the the wallet address tenantId. This would circumvent need for
// operator to pass in tenantId - is there any downside? Could the operator
// accidentally create it for the wrong tenant this way? I dont think so...
// Haven't fully thought through the implications, but consider this use case is
// atypical and tenant will usually be creating these (does that inform anything?)
const options: CreateQuoteOptions = {
walletAddressId: args.input.walletAddressId,
tenantId: args.input.tenantId,
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/graphql/resolvers/receiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,14 @@ export const getReceiver: QueryResolvers<ApolloContext>['receiver'] = async (

export const createReceiver: MutationResolvers<ApolloContext>['createReceiver'] =
async (_, args, ctx): Promise<ResolversTypes['CreateReceiverResponse']> => {
// TODO: do we need to check tenantId for remote args.input.walletAddressUrl?
// Not found on P2P example because wa url is remote - not sure if we can (probably not?)
// nor necessarily need to do such a check

const receiverService = await ctx.container.use('receiverService')

const receiverOrError = await receiverService.create({
tenantId: ctx.tenantId,
walletAddressUrl: args.input.walletAddressUrl,
expiresAt: args.input.expiresAt
? new Date(args.input.expiresAt)
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@ export function initIocContainer(
if (config.enableTelemetry) {
telemetry = await deps.use('telemetry')
}
// Problem if moving access control check to existing service methods such as:
// walletAddressService, incomingPaymentService.
// Will not have tenantId/isOperator in connector.
return await createConnectorService({
logger: await deps.use('logger'),
redis: await deps.use('redis'),
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/open_payments/receiver/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ export async function getLocalIncomingPayment(
return undefined
}

// Problem if moving access control check to existing service methods:
// - will not have tenantId/isOperator in everywhere this is called (ie outgoing payment worker)
const incomingPayment = await deps.incomingPaymentService.get({
id: urlParseResult.id
})
Expand Down

0 comments on commit 173b6b2

Please sign in to comment.