Updating users roles via Netlify Serverless function (When logged in as another user)

Current netlify site (https://practice.detready.com/)

Hi there!

I’m struggling with something and I hoping someone out there can help.

I am currently allowing sign ups via Netlify identity and setting the user to Free in the roles under meta data when they do so.
They are also able to sign up for Premium via stripe, this then removes free and sets Premium as a role.

I am looking at making an admin screen to handle updating the user role when NOT logged in as that specific user.

My thinking:
Hit a netlify serverless function with a user ID and role as query string parameters to update a user e.g:

https://practice.detready.com/.netlify/functions/update-user-role?userId={userId}&role={role}

My confusin sets in when providing a bearer token? bit lost here

Is this possible? can anyone help point me in the right direction?

I appreciate the help and time reading this :slight_smile:

Bit of an update, I now realise that the Bearer token is just a personal token created in Netlify (Unless i’m wrong?)

This is what I have so far, getting a service code of 200 which is great, but doesn’t seem to be updating the user :confused:

(I will be making this more secure, just need to get it going for now)

import fetch from "node-fetch"

exports.handler = async (event, context) => {
  var identityUrl = "https://XX/.netlify/identity"
  var userId = decodeURIComponent(event.queryStringParameters.userId);
  var role = decodeURIComponent(event.queryStringParameters.role);
  var bearerToken = 'XX';
  
 try {

  await fetch(`${identityUrl}/admin/users/${userId}`, {
    method: 'PUT',
    headers: {
      Authorization: `Bearer ${bearerToken}`,
    },
    body: JSON.stringify({
      app_metadata: {
        roles: [role],
      },
    }),
  });

  return {
   headers: {
     "header-1": "set-user-role",
     'content-type': 'application/json',
     'Access-Control-Allow-Origin': "*"
   },
   body: JSON.stringify({ received: true }),
   statusCode: 200
 }
} 
catch (err) {
  return {
    headers: {
      'content-type': 'application/json',
      'Access-Control-Allow-Origin': "*"
    },
    body: JSON.stringify({
      error: err,
      body: `Webhook Error: ${err.message}`,
    }),
    statusCode: 400
  }
}
};

When you use a serverless function request with the Bearer token in the header, you get access to the identity in the context. I’m not sure if passing the user ID directly is the correct way to go and if it’ll even work. But, here’s an example how to use it as per the official docs:

From the frontend, if you use GoTrueJS:

import GoTrue from 'gotrue-js'

const auth = new GoTrue({
  APIUrl: 'https://<your domain name>/.netlify/identity'
})

/// rest of the code

// You can also do this during sign-up by using auth.signup().
// Read here: https://github.com/netlify/gotrue-js

auth.login(email, password).then(user => {
  fetch('/.netlify/functions/update-user-role/?role=premium', {
    headers: {
      Authorization: 'Bearer ' + user.access_token,
    }
  }).then(response => {
    response.json()
  }).then(data => {
    // do your stuff
  })
})

Then, in the serverless function:

import fetch from 'node-fetch'

export async function handler(event, context) {

  const {identity, user} = context.clientContext

  if (user) {

    return fetch(identity.url + '/admin/users/' + user.sub, {
      method: 'put',
      body: JSON.stringify({
        app_metadata: {
          roles: [event.queryStringParameters.role]
        }
      })
      headers: {
        Authorization: 'Bearer ' + identity.token
      },
    }).then(response => {
      return response.json()
    }).then(data => {
      return {
        statusCode: 204
      }
    })

  } else {

    return {
      statusCode: 401,
     body: JSON.stringify('Unauthorized')
    }

  }

}

This way, you get a short-lived admin access that you can use to make changes to users.

I’m however not sure how secure this method is. Because my guess is that, anyone can access their auth token from the dev tools and call your serverless function with that header. The function would probably allow them to make changes themselves. I think you should probably use identity-signup function as I think it can only be invoked through the signup event (I might be wrong about it).

Note, the above function would probably only work when bundler is set to esbuild.

P.S.: I’ve directly written the code in the text box and not in an IDE, so I might have missed some syntax or indentation. Do not copy-paste as it is.

That makes a lot of sense!

I really appreciate the response! I will have a go later (when I get out of work) on at a what you have mentioned and report back!

Thank you so much for taking the time :clap: :muscle:

Actually! Will this achieve my goal of updating a different users role? as far as my understanding is that will just update the logged in users role, which is something I am already doing.

Hi @carvill,

Yes, the end result is probably the same and I might have misunderstood your question a bit. Sorry about that. What workflow are you trying to target by updating another user’s role? I’m really curious.

However, to answer that part, the only way to do that is to get that user’s auth token. The function will use that auth token to determine the user.

Ahh any idea how I can generate a users Auth token?

The purpose of this, is to build an admin panel. Allowing users with the right roles + other security methods will be able to upgrade users to premium or downgrade users to free from one admin screen.

Does that make more sense?

Thanks again for your time on this.

Oh, so you wish to build an admin panel for your own management? That should be possible, but just to clarify, would you be logged in using Identity too or do you have any other auth for the admin dashboard?

Yeah that’s correct, planning on using netlify identity for this too at least for the initial concept. Do you see any issues with this?

I think I might have thought of a way, though I’d have to test it. If it’s not super urgent, do you mind if I revert to you after a few hours or by tomorrow?

Yeah that is fine and appreciate that! Thank you! :pray:

Hey @carvill,

Thank you for your patience. I’ve brought some good news! Turns out it’s possible. Definitely a hack, but it works (at least worked in my production tests :joy:, hope it does for you too).

So yeah, the solution is simple in concept and it’s actually great that you’re going to use the admin account thought Netlify Identity too. So what you’ve got to do is this:

If you’re using GoTrueJS to generate a custom sign-in/sign-up flow, you can do it like this:

import GoTrue from 'gotrue-js'

// call this when you're already logged in as yourself
const admin = new GoTrue({
  APIUrl: 'https://<your-wesbsite>/.netlify/identity'
}).currentUser()

fetch('/.netlify/functions/updateRole/', {
  method: 'patch',
  body: JSON.stringify({
    email: 'target email address', // dynamically fill these values as needed
    role: 'role to apply'
  }),
  headers: {
    'content-type': 'application/json',
    Authorization: `Bearer ${admin.token.access_token}`
  }
}).then(response => {
    return response.json()
  }).then(updatedUser => {
    // updatedUser is the user with new role(s) 
    console.log(updatedUser)
  })

Even if you use Netlify identity Widget, the process is going to be fairly similar, except instead you’d get your own admin account like this: const admin = netlifyIdentity.currentUser().

Finally, in your serverless function (updateRole.js):

import fetch from 'node-fetch'

export async function handler(event, context) {
  const payload = JSON.parse(event.body)
  const identity = context.clientContext.identity
  return fetch(`${identity.url}/admin/users`, {
    headers: {
      Authorization: `Bearer ${identity.token}`
    }
  }).then(response => {
    return response.json()
  }).then(data => {
    const requiredUser = data.users.filter(user => {
      return user.email === payload.email
    })
    return fetch(`${identity.url}/admin/users/${requiredUser[0].id}`, {
      method: 'put',
      body: JSON.stringify({
        app_metadata: {
          roles: [payload.role]
        }
      }),
      headers: {
        Authorization: `Bearer ${identity.token}`
      }
    }).then(response => {
      return response.json()
    }).then(updatedUser => {
      return {
        statusCode: 200,
        body: JSON.stringify(updatedUser)
      }
    })
  })
}

You can simply copy-paste it. To add an explanation on how it works:

Basically, you are sending your own auth token (something that’s required to use Identity stuff inside functions), instead of your users’. This won’t make any difference, only that you could login to your (admin) account. Our problem was that we need to get an auth token to send along with the fetch as a header. This was not possible as we needed to login as the user, but now we don’t. You can login as yourself and get the token and that would work too.

Once you get your identity instance initialised inside the function, there’s an endpoint you can hit to get all users: GitHub - netlify/gotrue-js: JavaScript client library for GoTrue. That’s what we do. We get a list of all users for your identity instance and then filter the one we need. We filter it based on the payload data you send from your frontend. The email you specify there would be matched against the list of users you’ve. Thus, you’d get the required user.

Once you get the required user, you can get their ID and that’s all needed to make another request to update their role. The role is the one you send as payload from your frontend.

Note that, you’d have to add error handling and test for edge cases. This is just a primitive demo to update the roles without logging in as the user. Also, you probably need to add another layer of security. For example, in the current state, anyone with a valid auth token (any user of your identity instance), can hit your function with a JSON payload and update themselves. So, you can do some checks internally to see if the user object carried in the context is your account or not (you can check for email address, or check the role or other meta data). If you can verify it as yourself, only then proceed with the function, or return a 401 (unauthorised).

Hope this helps!

Amazing! will have a look at that today!!! Cheers so much for your help

1 Like

Sorry for the slow response I have been trying to get this to work from my side but I am struggling!

I get this error in my serverless function logs

4:46:07 PM: 2021-07-29T15:46:07.470Z	undefined	ERROR	Uncaught Exception 	{"errorType":"Runtime.UserCodeSyntaxError","errorMessage":"SyntaxError: Cannot use import statement outside a module","stack":["Runtime.UserCodeSyntaxError: SyntaxError: Cannot use import statement outside a module","    at _loadUserApp (/var/runtime/UserFunction.js:98:13)","    at Object.module.exports.load (/var/runtime/UserFunction.js:140:17)","    at Object.<anonymous> (/var/runtime/index.js:43:30)","    at Module._compile (internal/modules/cjs/loader.js:999:30)","    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)","    at Module.load (internal/modules/cjs/loader.js:863:32)","    at Function.Module._load (internal/modules/cjs/loader.js:708:14)","    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)","    at internal/main/run_main_module.js:17:47"]}
4:46:07 PM: 2021-07-29T15:46:07.689Z	undefined	ERROR	Uncaught Exception 	{"errorType":"Runtime.UserCodeSyntaxError","errorMessage":"SyntaxError: Cannot use import statement outside a module","stack":["Runtime.UserCodeSyntaxError: SyntaxError: Cannot use import statement outside a module","    at _loadUserApp (/var/runtime/UserFunction.js:98:13)","    at Object.module.exports.load (/var/runtime/UserFunction.js:140:17)","    at Object.<anonymous> (/var/runtime/index.js:43:30)","    at Module._compile (internal/modules/cjs/loader.js:999:30)","    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)","    at Module.load (internal/modules/cjs/loader.js:863:32)","    at Function.Module._load (internal/modules/cjs/loader.js:708:14)","    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)","    at internal/main/run_main_module.js:17:47"]}
4:46:07 PM: 880460c5 Duration: 173.90 ms	Memory Usage: 15 MB	4:46:07 PM: Unknown application error occurred
Runtime.UserCodeSyntaxError

I have a Vue function that runs this…

setUserRole(role) {
        // // call this when you're already logged in as yourself
        const admin = netlifyIdentity.currentUser();

        fetch('/.netlify/functions/updateRole/', {
          method: 'patch',
          body: JSON.stringify({
            email: this.userEmail, // dynamically fill these values as needed
            role: role
          }),
          headers: {
            'content-type': 'application/json',
            Authorization: `Bearer ${admin.token.access_token}`
          }
        }).then(response => {
            return response.json();
          }).then(updatedUser => {
            // updatedUser is the user with new role(s) 
            console.log(updatedUser)
          })
        }      
      }

Then inside my serverless functin updateRole :

import fetch from 'node-fetch'

export async function handler(event, context) {
  const payload = JSON.parse(event.body)
  const identity = context.clientContext.identity
  return fetch(`${identity.url}/admin/users`, {
    headers: {
      Authorization: `Bearer ${identity.token}`
    }
  }).then(response => {
    return response.json()
  }).then(data => {
    const requiredUser = data.users.filter(user => {
        debugger;
      return user.email === payload.email
    })
    return fetch(`${identity.url}/admin/users/${requiredUser[0].id}`, {
      method: 'put',
      body: JSON.stringify({
        app_metadata: {
          roles: [payload.role]
        }
      }),
      headers: {
        Authorization: `Bearer ${identity.token}`
      }
    }).then(response => {
        console.log(response.json());
      return response.json();

    }).then(updatedUser => {
      return {
        statusCode: 200,
        body: JSON.stringify(updatedUser)
      }
    })
  })
}

I can’t seem to figure out where I am going wrong in comparison to your example?

Sorry, I forgot to mention, you need to add this to netlify.toml:

[functions]
  node_bundler = "esbuild"

This will bundle it into a module.

Also, I’m not sure what the debugger is here:

Getting closer i’m sure!

However nothing in the function log since the addition of:

[functions]
  node_bundler = "esbuild"

But no change of the user role when calling the function :frowning: !

I did re-read your response and you say the following:

So yeah, the solution is simple in concept and it’s actually great that you’re going to use the admin account thought Netlify Identity too:

I might be looking into this too much, but do I need to use my netlify main login rather than a user that has signed up via Netlify identity?

Also I have removed the debugger, that was my bad :confused:

No, what I meant was, the account that you’d be using as an admin is also one of your Netlify Identity members.

That is strange. Could you add some log statements to capture the progress at each step? For example, you could do something like:

import fetch from 'node-fetch'

export async function handler(event, context) {
  const payload = JSON.parse(event.body)
  const identity = context.clientContext.identity
  console.log('step 1 done')
  return fetch(`${identity.url}/admin/users`, {
    headers: {
      Authorization: `Bearer ${identity.token}`
    }
  }).then(response => {
    return response.json()
  }).then(data => {
    console.log('step 2 done')
    const requiredUser = data.users.filter(user => {
      return user.email === payload.email
    })
    return fetch(`${identity.url}/admin/users/${requiredUser[0].id}`, {
      method: 'put',
      body: JSON.stringify({
        app_metadata: {
          roles: [payload.role]
        }
      }),
      headers: {
        Authorization: `Bearer ${identity.token}`
      }
    }).then(response => {
        console.log(response.json());
      return response.json();
    }).then(updatedUser => {
      console.log('step 3 done')
      return {
        statusCode: 200,
        body: JSON.stringify(updatedUser)
      }
    })
  })
}

Then we can see till where the code is actually executing without a problem.

Also, I see that you’ve this line in your client-side code:

It should log the updated user. Doesn’t it mention the new role in it?

Okay, it seems like its not getting into the serverless function now!

Got these errors in my console window obviously the XX is my domain)

patch https://XX/.netlify/functions/updateRole/ 405

and

Uncaught (in promise) SyntaxError: Unexpected end of JSON input

The 405 Method Not Allowed error occurs when the web server is configured in a way that does not allow you to perform a specific action for a particular URL. It’s an HTTP response status code that indicates that the request method is known by the server but is not supported by the target resource.

Anything extra I need to do to allow this?

fetch('/.netlify/functions/updateRole/', {
          method: 'patch',
          body: JSON.stringify({
            email: this.userEmail, // dynamically fill these values as needed
            role: role
          }),
          headers: {
            'content-type': 'application/json',
            Authorization: `Bearer ${admin.token.access_token}`
          }
        }).then(response => {
            return response.json();
          }).then(updatedUser => {
            // updatedUser is the user with new role(s) 
            console.log(updatedUser)
          })
        }   

Could you try changing it to post? I think I might have changed it to patch just for the fun of it after I got the code working otherwise. I don’t remember if that’s the case exactly, but yeah, post should do it.