Application Development

Least privilege for Cloud Functions using Cloud IAM

least-privilege-cloud-functions-blog-hero.png

Cloud Functions enables you to quickly build and deploy lightweight microservices and event-driven workloads at scale. Unfortunately when building these services, security is often an after-thought, resulting in data leaks, unauthorized access, privilege escalation, or worse.

Fortunately, Cloud Functions makes it easy to secure your services by enabling you to build least privilege functions that minimize the surface area for an attack or data breach.

What is least privilege?

The principle of least privilege states that a resource should only have access to the exact resource(s) it needs in order to function. For example, if a service is performing an automated database backup, the service should be restricted to read-only permissions on exactly one database. Similarly, if a service is only responsible for encrypting data, it should not have permissions for decrypting data. Providing too few permissions prohibits the service from completing its task, but providing too many permissions can have rippling security ramifications.

If an attacker is able to gain access to a service that doesn’t follow the principles of least privilege, they may be able to force the service to behave nefariously—for example access customer data, delete critical infrastructure, or steal confidential business intelligence.

How do we achieve least privilege in Cloud Functions?

By default, all Cloud Functions in a Google Cloud project share the same runtime service account. This service account is bound to the function, and is used to generate credentials for accessing Cloud APIs and services. This default service account has the Editor role, which includes all read permissions, plus permissions for actions that modify state, such as changing existing resources. This enables a seamless development experience, but may include overly broad permissions for your functions, since most functions only need to access a subset of resources.

To practice the principle of least privilege in Cloud Functions, you can create and bind a unique service account to each function, granting the service account only the most minimal set of permissions required to execute the function.

Calling GCP services
Consider the following example function, which is triggered when a file is uploaded to a Cloud Storage bucket. The function reads the contents of the file, transforms it, and then writes the transformed file back to the same Cloud Storage bucket.

  exports.transformImage = (event, context) => {
  const object = event.data;
  const bucket = gcs.bucket(object.bucket);

  bucket.file(object.name).get().then((data) => {
    return transform(data[0]);
  }).then((transformedData) => {
    return bucket.file(object.name + '_transformed').save(transformedData);
  });
});

Reviewing the Cloud Storage IAM permissions, this function needs the following permissions on the Cloud Storage bucket:

  • storage.objects.get
  • storage.objects.create

We will use the ability to set a service account on an individual Cloud Function, giving each function its own service account with unique permissions. To do this:

1. Create a new service account. The service account name must be unique within the project. For more information, please see the managing service accounts documentation.

  gcloud iam service-accounts create transform-image-sa

2. Grant the service account minimal IAM permissions. By default, service accounts have very minimal permissions. To use the service account with a function, we need to add bindings for the service account to the resources it needs to access.

  gsutil iam set \
  serviceAccount:transform-image-sa@[PROJECT_ID].iam.gserviceaccount.com:objectAdmin \
  gs://mybucket

3. Deploy a function that uses the new service account. When deploying our function, we use the --service-account flag to specify that our function should run as our custom service account instead of the default account. Since the function executes as the service account, our function inherits the permissions granted to the service account.

  gcloud functions deploy transformImage \
  --service-account "transform-image-sa@[PROJECT_ID].iam.gserviceaccount.com"

The Cloud Storage Object Admin role includes the following permissions:

  • storage.objects.create
  • storage.objects.update
  • storage.objects.delete
  • storage.objects.get
  • storage.objects.list
  • storage.objects.getIamPolicy
  • storage.objects.setIamPolicy

Permissions are very fine-grained access control rules. One or more permissions are usually combined to form a role. There are pre-built roles (like the Object Admin role above), and you also have the ability to generate custom roles with very specific sets of permissions.

If you recall, our function only needs the create and get permissions, but the role we picked includes five additional permissions that are not needed. While we have gotten closer, we are still not fully practicing the principle of least privilege.

There are no pre-built roles that includes only the two permissions we need, so we need to create a custom role in our project and grant that role to the service account on the bucket:

1. Create a custom role with exactly the two permissions needed.

  gcloud iam roles create simpleStorageRole \
  --project "[PROJECT_ID]" \ 
  --title "simpleStorageRole" \ 
  --description "get and create storage objects" \
  --permissions "storage.objects.create,storage.objects.get"

2. Grant the service account access to the custom role on the bucket:

  gsutil iam set \
  serviceAccount:transform-image-sa@[PROJECT_ID].iam.gserviceaccount.com:roles/simpleStorageRole \
  gs://mybucket

3. Deploy myFunction bound to the service account which has permission to invoke otherFunction:

  gcloud functions deploy transformImage \
  --service-account "transform-image-sa@[PROJECT_ID].iam.gserviceaccount.com"

Calling other functions
In addition to calling a Google Cloud service like Cloud Storage, you may want to a function to call ("invoke") another function. The concept of least privilege also applies to restricting which functions or users can invoke your function. You can achieve this by using the Cloud IAM roles/cloudfunctions.invoker role. Set IAM policies on each function to enforce that only certain users, functions, or services can invoke the function.

A good first step is to ensure that a function cannot be invoked by the public. For example, remove the special allUsers member from the roles/cloudfunctions.invoker role associated with the function:

  gcloud beta functions remove-iam-policy-binding [FUNCTION_NAME] \
  --member allUsers \
  --role roles/cloudfunctions.invoker

This makes your function private and restricts the ability to invoke the function unless the caller has cloudfunctions.invoker permissions. If a caller does not have this permission, the request is rejected, your function is not invoked and you avoid billing charges.

Once a Cloud Function is private, you will need to add authentication when invoking it. Specifically, the caller needs a Google-signed identity token (a JSON Web Token) in the Authorization header of the outbound HTTP request. The audience (aud field) must be set to the URL of the function you are calling.

One of the easiest ways to get such a token is by querying the compute metadata server:

  exports.callingFunction = async (req, res) => {
  const otherFunction = 'https://us-central1-project_id.cloudfunctions.net/otherFunction;

  // Set up metadata server request to get a token
  const metadataServerTokenURL = 'http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=';
  const tokenRequestOptions = {
      uri: metadataServerTokenURL + otherFunction,
      headers: {
          'Metadata-Flavor': 'Google'
      }
  };

  // Fetch the token, then provide the token in the Authorization header
  // of the request to the receiving function
  try {
    const token = await request(tokenRequestOptions);
    // "Authorization: bearer ${token}"
    const response = await request(otherFunction).auth(null, null, true, token);
  } catch (err) {/* handle errors */}
};

For example, suppose we have two functions – myFunction and otherFunction, where myFunction needs permission to invoke otherFunction. To accomplish this while also following the principle of least privilege we would:

1. Create a new, dedicated service account:

  $ gcloud iam service-accounts create my-function-service-account

2. Grant the service account permissions to invoke otherFunction (this assumes that otherFunction is already running and deployed):

  gcloud beta functions add-iam-policy-binding otherFunction \
  --member serviceAccount:my-function-service-account@[PROJECT_ID].iam.gserviceaccount.com \
  --role roles/cloudfunctions.invoker

3. Deploy myFunction bound to the service account which has permission to invoke otherFunction:

  gcloud functions deploy myFunction \
  --service-account my-function-service-account@[PROJECT_ID].iam.gserviceaccount.com

Cloud Run and App Engine (when using IAP) can also perform similar validation.

When calling other services
If you are calling a compute service that you control that does not have Cloud IAM policies restricting access (like a Compute Engine VM), you can follow the same steps to generate the token and then validate the Google-signed identity token yourself.

Next steps

We hope this post illustrates the importance of the principle of least privilege and provides concrete steps you can take to improve the security of your serverless functions. If you want to learn more about Cloud Functions security, you can watch Serverless Security made Simple from Cloud Next 2019. If you want to learn more about how Google Cloud is enabling organizations to improve their security, including adopting the principle of least privilege, sign up for the Policy Intelligence alpha.