Introduction

Welcome back to GCP Security and Networking Fundamentals! In this lesson, we move beyond user and group management to explore a core GCP security concept: service accounts and IAM roles. While users and groups are great for managing human access, service accounts are designed for applications and services running on Google Cloud. They provide a secure, manageable way for your workloads — such as virtual machines and serverless functions — to access other GCP resources. By the end of this lesson, you'll know how to create service accounts, grant them the right permissions, assign them to resources, and review their access, all using Python and GCP APIs.

Understanding the Power of Service Accounts

Before we dive into implementation, let's clarify what service accounts are and why they're essential in GCP. Unlike user accounts, which represent people, a service account is a special identity intended for applications, virtual machines, or other compute workloads. Service accounts allow these workloads to authenticate to GCP APIs and access resources securely, without embedding user credentials or managing passwords.

Think of a service account as a digital identity badge for your application. When you assign a service account to a VM or a Cloud Function, that workload can act as the service account and use its permissions to interact with other GCP services. This approach keeps your environment secure and makes it easy to follow best practices like least privilege.

Controlling Access to Service Accounts

In GCP, access control is managed through IAM policies. You can grant permissions to service accounts, users, or groups by assigning them IAM roles on specific resources. Additionally, you can control which identities are allowed to "impersonate" or "act as" a service account by granting the roles/iam.serviceAccountUser role. This ensures that only trusted users or services can use a service account's identity to access resources.

Permissions in GCP are always explicit: you must grant a service account the necessary roles to access resources, and you must grant users or other service accounts the ability to use or manage a service account. This separation of duties helps maintain a strong security posture.

Authentication Note: mock environment vs. production credentials

Throughout this course, Unit 2 uses the same mock GCP environment as the other units. That means all discovery clients should be built with:

  • os.environ.get("GCP_DISCOVERY_URL")
  • credentials=None
  • static_discovery=False

Why is credentials=None acceptable here? Because requests are sent to a local practice service rather than real Google Cloud APIs, so real authentication is not required.

Why do we also set static_discovery=False? Some versions of google-api-python-client can otherwise fall back to built-in discovery metadata and send requests to real Google API endpoints. In this course, we want to force the client to use the mock discovery URL so all practice code stays inside the local training environment.

In production, things work differently:

  • your Python client must authenticate to Google Cloud
  • the recommended approach is usually Application Default Credentials (ADC)
  • when a workload runs on GCP with an attached service account, the workload can often obtain credentials automatically from the metadata service
  • when running locally, ADC is commonly set up with gcloud auth application-default login or by using a configured service account in a secure way

So, in this lesson, remember the distinction:

  • mock environment: credentials=None and static_discovery=False
  • real GCP environment: authenticate with ADC or another supported credential flow
Creating Your First Service Account

Let's create a service account that will allow a Compute Engine VM to read objects from Cloud Storage. We'll use the Google API discovery client to automate this process in the course mock environment.

import os
from googleapiclient import discovery

PROJECT_ID = os.environ.get("PROJECT_ID", "your-gcp-project-id")
ZONE = os.environ.get("ZONE", "us-central1-a")
GCP_DISCOVERY_URL = os.environ.get("GCP_DISCOVERY_URL")

if not GCP_DISCOVERY_URL:
    raise RuntimeError("GCP_DISCOVERY_URL must be set for the mock-only client.")

iam = discovery.build(
    "iam", "v1",
    discoveryServiceUrl=GCP_DISCOVERY_URL,
    cache_discovery=False,
    static_discovery=False,
    credentials=None
)

crm = discovery.build(
    "cloudresourcemanager", "v1",
    discoveryServiceUrl=GCP_DISCOVERY_URL,
    cache_discovery=False,
    static_discovery=False,
    credentials=None
)

compute = discovery.build(
    "compute", "v1",
    discoveryServiceUrl=GCP_DISCOVERY_URL,
    cache_discovery=False,
    static_discovery=False,
    credentials=None
)

def create_service_account(sa_name, sa_display_name):
    """Create a new service account."""
    service_account = {
        'accountId': sa_name,
        'serviceAccount': {
            'displayName': sa_display_name
        }
    }
    sa = iam.projects().serviceAccounts().create(
        name=f'projects/{PROJECT_ID}',
        body=service_account
    ).execute()
    return sa['email']

def grant_role_to_service_account(sa_email, role):
    """Grant a predefined IAM role to the service account at the project level."""
    policy = crm.projects().getIamPolicy(
        resource=PROJECT_ID, body={}
    ).execute()
    binding = next((b for b in policy['bindings'] if b['role'] == role), None)
    member = f"serviceAccount:{sa_email}"
    if binding:
        if member not in binding['members']:
            binding['members'].append(member)
    else:
        policy['bindings'].append({'role': role, 'members': [member]})
    crm.projects().setIamPolicy(
        resource=PROJECT_ID, body={'policy': policy}
    ).execute()

def create_vm_with_service_account(sa_email, instance_name, zone):
    """Create a Compute Engine VM with the given service account."""
    config = {
        'name': instance_name,
        'machineType': f"zones/{zone}/machineTypes/e2-micro",
        'disks': [{
            'boot': True,
            'autoDelete': True,
            'initializeParams': {
                'sourceImage': 'projects/debian-cloud/global/images/family/debian-11'
            }
        }],
        'networkInterfaces': [{
            'network': 'global/networks/default',
            'accessConfigs': [{'type': 'ONE_TO_ONE_NAT', 'name': 'External NAT'}]
        }],
        'serviceAccounts': [{
            'email': sa_email,
            'scopes': ['https://www.googleapis.com/auth/cloud-platform']
        }]
    }
    compute.instances().insert(
        project=PROJECT_ID,
        zone=zone,
        body=config
    ).execute()

# Example usage:
sa_email = create_service_account('vm-storage-reader', 'VM Storage Reader')
grant_role_to_service_account(sa_email, 'roles/storage.objectViewer')
create_vm_with_service_account(sa_email, 'demo-vm', ZONE)

This code creates a service account, grants it the Storage Object Viewer role (read-only access to Cloud Storage), and launches a VM that uses this service account.

Zone note: what is us-central1-a?

In GCP, a region is a geographic area such as us-central1, and a zone is a specific deployment location inside that region, such as us-central1-a or us-central1-b.

When creating zonal resources like Compute Engine VMs, you must choose a zone. In this lesson, we use the ZONE environment variable and default it to us-central1-a simply to keep examples concrete and easy to run.

How do you choose a zone in practice?

  • pick a zone in the region where you want your workload to run
  • keep related resources close together when latency matters
  • consider availability and redundancy if you plan to spread workloads across zones

For this course, using a configurable ZONE variable is enough. In production, you may choose zones based on architecture, quotas, service availability, and resilience requirements.

Granting Permissions Through IAM Roles

A service account without permissions can't access any resources. In GCP, you grant permissions by assigning IAM roles to the service account. GCP provides many predefined roles for common use cases, such as roles/storage.objectViewer for read-only access to Cloud Storage or roles/datastore.user for Datastore access.

You can grant roles at different levels: project, folder, or resource. For most workloads, granting a role at the project level is sufficient, but you can scope it down to a specific bucket or database for tighter security.

# Grant Storage Object Viewer role to the service account at the project level
grant_role_to_service_account(sa_email, 'roles/storage.objectViewer')

Predefined roles are maintained by Google and cover most common scenarios. For more granular control, you can create custom roles with exactly the permissions your workload needs.

Assigning Service Accounts to Compute Engine VMs

When you create a Compute Engine VM, you can specify which service account it should use. The VM will automatically receive credentials for that service account, allowing applications running on the VM to access GCP resources according to the service account's permissions.

If you need to change the service account for an existing VM, you can do so using the Compute Engine API or the Google Cloud Console. The VM will then use the new service account's permissions for all future API calls.

# See create_vm_with_service_account() in the previous section for how to assign a service account at VM creation.

Note: In real GCP environments, applications on the VM should usually rely on Application Default Credentials (ADC) rather than embedding keys in code. When a service account is attached to the VM, the workload can typically obtain credentials automatically.

Production Note: Custom Roles and Least Privilege
  • In these examples, we use predefined roles for simplicity.
  • In production, follow the principle of least privilege: grant only the permissions your workload needs, and nothing more.
  • You can create custom roles in GCP to specify exactly which permissions are allowed.

Example: Custom role for read-only access to a specific Cloud Storage bucket

def create_custom_role(role_id, title, permissions):
    """Create a custom IAM role at the project level."""
    role = {
        'roleId': role_id,
        'role': {
            'title': title,
            'description': 'Read-only access to a specific bucket',
            'includedPermissions': permissions,
            'stage': 'GA'
        }
    }
    iam.projects().roles().create(
        parent=f'projects/{PROJECT_ID}',
        body=role
    ).execute()

# Example permissions for read-only access to storage buckets
permissions = [
    "storage.buckets.get",
    "storage.objects.get",
    "storage.objects.list"
]
create_custom_role("customStorageBucketReader", "Custom Storage Bucket Reader", permissions)

Best practices:

  • Grant roles at the lowest possible resource level (e.g., a single bucket, not the whole project).
  • Regularly review service account permissions and remove unnecessary access.
Building Specialized Service Accounts for Cloud Functions

Different GCP services require different service accounts and permissions. Let's create a service account for a Cloud Function that needs to write to Firestore.

functions = discovery.build(
    "cloudfunctions", "v1",
    discoveryServiceUrl=GCP_DISCOVERY_URL,
    cache_discovery=False,
    static_discovery=False,
    credentials=None
)

def create_function_service_account(sa_name, sa_display_name):
    """Create a service account for Cloud Functions."""
    return create_service_account(sa_name, sa_display_name)

def grant_firestore_access(sa_email):
    """Grant Firestore user role to the service account."""
    grant_role_to_service_account(sa_email, 'roles/datastore.user')

def deploy_cloud_function_with_service_account(sa_email, function_name, location):
    """Deploy a Cloud Function with the specified service account."""
    function_body = {
        'name': f'projects/{PROJECT_ID}/locations/{location}/functions/{function_name}',
        'entryPoint': 'hello_world',
        'runtime': 'python39',
        'httpsTrigger': {},
        'serviceAccountEmail': sa_email,
        'sourceArchiveUrl': 'gs://your-source-bucket/your-function-source.zip'
    }
    functions.projects().locations().functions().create(
        location=f'projects/{PROJECT_ID}/locations/{location}',
        body=function_body
    ).execute()

# Example usage:
cf_sa_email = create_function_service_account('function-firestore-writer', 'Function Firestore Writer')
grant_firestore_access(cf_sa_email)
deploy_cloud_function_with_service_account(cf_sa_email, 'demo-function', 'us-central1')

This creates a service account, grants it Firestore access, and deploys a Cloud Function using that service account.

Analyzing Service Account Permissions and Auditing Access

After creating service accounts and assigning roles, it's important to review their permissions and audit access. You can use the IAM API to list the roles granted to a service account.

def get_service_account_roles(sa_email):
    """List all IAM roles granted to a service account at the project level."""
    policy = crm.projects().getIamPolicy(
        resource=PROJECT_ID, body={}
    ).execute()
    member = f"serviceAccount:{sa_email}"
    roles = []
    for binding in policy['bindings']:
        if member in binding['members']:
            roles.append(binding['role'])
    return roles

# Example usage:
roles = get_service_account_roles(sa_email)
print(f"Roles for {sa_email}: {roles}")

For auditing, GCP provides Cloud Audit Logs, which record all access to resources, including which service account was used. You can review these logs in the Cloud Console or export them for analysis.

Orchestrating Complete Service Account Workflows

Let's put it all together: create service accounts, grant roles, assign them to resources, and inspect their permissions.

if __name__ == "__main__":
    # Create a service account for a VM
    sa_email = create_service_account('vm-storage-reader', 'VM Storage Reader')
    grant_role_to_service_account(sa_email, 'roles/storage.objectViewer')
    create_vm_with_service_account(sa_email, 'demo-vm', ZONE)
    print(f"Created VM with service account: {sa_email}")

    # Create a service account for a Cloud Function
    cf_sa_email = create_function_service_account('function-firestore-writer', 'Function Firestore Writer')
    grant_firestore_access(cf_sa_email)
    deploy_cloud_function_with_service_account(cf_sa_email, 'demo-function', 'us-central1')
    print(f"Deployed Cloud Function with service account: {cf_sa_email}")

    # Inspect permissions
    print(f"VM Service Account Roles: {get_service_account_roles(sa_email)}")
    print(f"Function Service Account Roles: {get_service_account_roles(cf_sa_email)}")
Conclusion and Next Steps

In this lesson, you've learned how GCP uses service accounts and IAM roles to manage secure, fine-grained access for applications and services. You now know how to create service accounts, grant them predefined or custom roles, assign them to resources like VMs and Cloud Functions, and review their permissions. In this course's mock environment, we build discovery clients with os.environ.get("GCP_DISCOVERY_URL"), credentials=None, and static_discovery=False so the client stays inside the mock environment. In real GCP environments, use Application Default Credentials (ADC) or another supported authentication flow. Remember to always follow the principle of least privilege: grant only the permissions needed, and regularly review access. In production, use custom roles and resource-level scoping to keep your cloud environment secure and manageable.

In the next lesson, we'll explore more advanced IAM features and best practices for securing your GCP workloads.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal