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

feat(backend): tenant support for wallet address (#3114) #3152

Draft
wants to merge 44 commits into
base: 2893/multi-tenancy-v1
Choose a base branch
from

Conversation

koekiebox
Copy link
Collaborator

@koekiebox koekiebox commented Dec 5, 2024

Changes proposed in this pull request

Context

Checklist

  • Related issues linked using fixes #number
  • Tests added/updated
  • Make sure that all checks pass
  • Bruno collection updated (if necessary)
  • Documentation issue created with user-docs label (if necessary)
  • OpenAPI specs updated (if necessary)

@koekiebox koekiebox added the do not merge Do not merge PRs with these label label Dec 5, 2024
@koekiebox koekiebox self-assigned this Dec 5, 2024
@github-actions github-actions bot added type: tests Testing related pkg: backend Changes in the backend package. pkg: frontend Changes in the frontend package. type: source Changes business logic pkg: mock-ase pkg: mock-account-service-lib labels Dec 5, 2024
@koekiebox koekiebox changed the title 3114/tenanted wallet addresses feat(backend): tenant support for wallet address (#3114) Dec 5, 2024
@koekiebox koekiebox changed the base branch from 2893/multi-tenancy-v1 to nl/3123/backend-tenant-service December 5, 2024 13:25
@github-actions github-actions bot added the pkg: auth Changes in the GNAP auth package. label Dec 9, 2024
@github-actions github-actions bot removed the pkg: frontend Changes in the frontend package. label Jan 7, 2025
packages/backend/src/app.ts Outdated Show resolved Hide resolved
async (parent, args, ctx): Promise<ResolversTypes['WalletAddress']> => {
const walletAddressService = await ctx.container.use('walletAddressService')
const walletAddress = await walletAddressService.get(args.id)
if (!walletAddress) {
if (!walletAddress || walletAddress.tenantId !== ctx.tenant.id) {
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 want to ensure we are not being 'hacked', the tenant can only access their own wallet addresses.

Copy link
Contributor

@BlairCurrey BlairCurrey Jan 8, 2025

Choose a reason for hiding this comment

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

Jason what are you thoughts on where we do the access control (ie does the tenantId match) for the different cases? Updating, getting, etc.

In this PR we are mixing whether the tenant id gets passed into the service layer or not here. The update passes it in and uses it in the where to find the resource to update, and the get does not. Looking at the implementation and usage in this PR I think its very simple and like that. I just wonder what happens with the worker where I think the pattern breaks down. And is it a problem if I ask "OK how are you doing access control?" and then we have to go in the resolver for some of it and the service for other parts across all resources. And I guess we need to make access control tests for each resolver/service that does it.

For the worker, we wont have a tenantId since the request doesnt originate from a tenant. So the walletAddressService.update({tenantId ... }) (or similar) wont work. OutgoingPayment update would run into this, for example.

Nathan and I have had some discussions about where the access control happens. There were a few ideas I recall:

  1. always doing access control at the resolver or middleware level before the resolver. getting a wallet address? lookup wallet address and check tenantId, then run the resolver as normal and have not concept of the tenant in the service. This does add some extra calls but centralizes it and keeps the services totally tenantId agnostic. Do we need to care about the tenant everywhere in our application or just at the entry? This does have some complexity in terms of getting the resource id from the request in standard way (makes doing in middleware hard because things vary by resolver).
  2. make separate functions with shared logic for updating, getting, etc. a resource and doing access control or updating a resource and not doing access control. And maybe always doing the access control in the service layer.

Copy link
Contributor

Choose a reason for hiding this comment

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

I had a draft PR for a previous version of multi-tenancy with a helper function in the different services (quote, wallet address, etc) for checking if a tenant could access. Just to elaborate on option #1 above a bit.

https://github.com/interledger/rafiki/pull/3026/files#diff-1695f39120835125efdde9389ae4e400acc244ab1a89105ab531631f1606dff0

Copy link
Contributor

@BlairCurrey BlairCurrey Jan 8, 2025

Choose a reason for hiding this comment

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

Also, do we need an "is operator" check here?

For things like the create, update, etc. the operator needs to specify a tenant id. But I dont think thats the case for the get, and we need to let the operator bypass this check. Imagine they are on the page listing the wallet addresses. When they click one of them, the tenantId for that resource isnt getting set, and then this resolver fires.

Copy link
Contributor

@njlie njlie Jan 8, 2025

Choose a reason for hiding this comment

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

I think with operations like this where a single item is affected, the tenant id on the context should be considered within the permission of the requester - the middleware should take care if the tenant id is the "right" one, so it can be passed into the service layer without any security concern.

I would say isOperator is more relevant to pagination requests, where it would be necessary to know if it needs to be filtered, or if the request is to createTenant which should be operator-only.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ok, I have updated the logic to not have the tenantId be mandatory on the service layer.

In the GraphQL, we ensure that we have flexibility for providing an alternative tenantId (for when isOperator == true) than what is on the signature, as well as ensuring they are correct when fetching.

Example snippet that performs the validation on the resolver:

// The blow is done when fetching the walletAddress:
if (ctx) {
    const tenantId = tenantIdToProceed(
      ctx.isOperator,
      ctx.tenant.id,
      walletAddress.tenantId
    )
    if (!tenantId) {
      const err = WalletAddressError.InvalidTenantIdNotAllowed
      throw new GraphQLError(errorToMessage[err], {
        extensions: {
          code: errorToCode[err]
        }
      })
    }
  }

// Then in operations like create and update walletAddress:
 const tenantId = tenantIdToProceed(
      ctx.isOperator,
      ctx.tenant.id,
      args.input.tenantId
    )
    if (!tenantId) {
      const err = WalletAddressError.InvalidTenantIdNotAllowed
      throw new GraphQLError(errorToMessage[err], {
        extensions: {
          code: errorToCode[err]
        }
      })
    }

The function for determining the correct tenantId to use:

/**
 * The tenantId to use will be determined as follows:
 * - When an operator and the {tenantId} is present, return {tenantId}
 * - When an operator and {tenantId} is not present, return {signatureTenantId}
 * - When NOT an operator and {tenantId} is present, but does not match {signatureTenantId}, return {undefined}
 * - Otherwise return {signatureTenantId}
 *
 * @param isOperator is operator
 * @param signatureTenantId the signature tenantId
 * @param tenantId the intended tenantId
 */
export function tenantIdToProceed(
  isOperator: boolean,
  signatureTenantId: string,
  tenantId?: string
): string | undefined {
  if (isOperator && tenantId) return tenantId
  else if (isOperator) return signatureTenantId
  return tenantId && tenantId !== signatureTenantId
    ? undefined
    : signatureTenantId
}

Let me know what you think? @BlairCurrey and @njlie

Copy link
Contributor

Choose a reason for hiding this comment

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

@koekiebox instead of args.input.tenantId, we would be getting the tenantId from the header, right?

Copy link
Contributor

@mkurapov mkurapov Jan 13, 2025

Choose a reason for hiding this comment

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

@BlairCurrey

we need to let the operator bypass this check. Imagine they are on the page listing the wallet addresses.

When the operator is trying to paginate across all resources (e.g. all wallet addresses, for example), what should the behaviour be? we don't pass in the tenantId at all?

@koekiebox koekiebox requested a review from mkurapov January 8, 2025 12:39
@github-actions github-actions bot added the pkg: frontend Changes in the frontend package. label Jan 11, 2025
Comment on lines +396 to +399
if (this.config.env === 'test') {
const tenantService = await this.container.use('tenantService')
const tenant = await tenantService.get(this.config.operatorTenantId)
tenantApiSignatureResult = { tenant, isOperator: true }
Copy link
Contributor

Choose a reason for hiding this comment

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

ah, we basically got to the same conclusion :)

I ended up setting a tenant-id header in the apollo client during tests: https://github.com/interledger/rafiki/pull/3206/files#diff-5b481c1a1464d3e282df7102a0cb5882701fc2b2af81037e203812e13acf0176R414-R433

Both work, I'm trying to think whether we would actually want to be able to pass in a different tenant id during tests...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
do not merge Do not merge PRs with these label pkg: backend Changes in the backend package. pkg: frontend Changes in the frontend package. pkg: mock-account-service-lib pkg: mock-ase type: source Changes business logic type: tests Testing related
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Multi-Tenant] Tenanted Wallet Addresses
4 participants