Skip to main content

Google GitOps IAM bindings framework using StackQL

In this guide, we will demonstrate a GitOps framework for IAM Bindings (entitlements) management in GCP using StackQL, a powerful dev tool that enables querying and deploying cloud infrastructure and resources using SQL syntax.

Tested with embedded sql backend macos linux powershell

Google IAM background

Google Cloud Identity and Access Management (IAM) is a framework for managing access to Google Cloud resources, allowing administrators to define who can take specific actions on resources. It operates around identities (like users and service accounts), roles, and permissions, where roles assigned to these identities govern their access rights. This guide delves into managing IAM bindings, which link these roles to identities, using StackQL, a tool that simplifies cloud resource management with SQL syntax.

IAM bindings can be created at any of the following levels:

  • organization level
  • folder level
  • project level
  • resource level (Buckets, BigQuery Datasets, etc)
  • billing account level

IAM bindings applied at organization or folder level are inherited by all child objects.

Declarative binding definitions and GitOps

The guide will show you how to master IAM bindings in your source code repository, making it the source of truth for state and history, a concept known as GitOps, and how to use StackQL to apply changes using CI/CD workflows.

This example shows you how to map many members or principals to many roles from a json manifest stored in source control.

The GCP console, terraform, gcloud utilities only allow you to map one member to one or more roles in each operation

How it works

The framework that we will walk you through will deploy IAM bindings across all nodes in your GCP heirarchy, as well as creating custom roles (with curated permissions) - as opposed to predefined roles such as roles/bigquery.dataEditor for example.

Project directory structure

The following directory tree is used for this bindings deployment framework:

├── iql
│ ├── iam.iql
│ ├── ...
├── data
│ ├── iam.jsonnet
│ ├── iam
│ │ ├── bindings.json
│ │ ├── custom_roles.json

iql/iam.iql file

This is the main file which will be run by the stackql, using data/iam.jsonnet this will render and apply the transpiled policy bindings generated from the data in data/iam/bindings.json

this file should not need to be modified

Expand to see the iql/iam.iql file
/*
ROOT LEVEL (ORG AND FOLDER) IAM FOR SERVICE ACCOUNTS AND GROUPS
*/
{{ $root := . }}

/*** create custom roles ***/ {{ range .custom_roles }}

INSERT INTO google.iam.`organizations.roles`(
parent,
data__role,
data__roleId )
SELECT
'{{ $root.organization_id }}',
'{"title": "{{ .title }}", "description": "{{ .description }}", "stage": "{{ .stage }}", "includedPermissions": {{ .includedPermissions }}}',
'{{ .id }}'
;{{ end }}

/*** create org role bindings */

EXEC google.cloudresourcemanager.organizations.setIamPolicy
@resource = '{{ $root.organization_id }}'
@@json = '{
"policy": {
"bindings": {{ $root.bindings.org }}
}
}';

/*** create billing role bindings */

EXEC google.cloudbilling.billingAccounts.setIamPolicy
@resource = '{{ $root.billing_account_id }}'
@@json = '{
"policy": {
"bindings": {{ $root.bindings.billingacct }}
}
}';

/*** create nonprod folder role bindings */

EXEC google.cloudresourcemanager.folders.setIamPolicy
@resource = '{{ $root.nonprod_folder_id }}'
@@json = '{
"policy": {
"bindings": {{ $root.bindings.folders.nonprod }}
}
}';

/*** create prod folder role bindings */

EXEC google.cloudresourcemanager.folders.setIamPolicy
@resource = '{{ $root.prod_folder_id }}'
@@json = '{
"policy": {
"bindings": {{ $root.bindings.folders.prod }}
}
}';

/*** create datalabs folder role bindings */

EXEC google.cloudresourcemanager.folders.setIamPolicy
@resource = '{{ $root.datalabs_folder_id }}'
@@json = '{
"policy": {
"bindings": {{ $root.bindings.folders.datalabs }}
}
}';

/*** create stackql-audit project level role bindings */

EXEC google.cloudresourcemanager.folders.setIamPolicy
@resource = 'projects/stackql-audit'
@@json = '{
"policy": {
"bindings": {{ $root.bindings.projects.stackql_audit }}
}
}';

/*** create role bindings for buckets in stackql-terraform */ {{ range $root.bindings.buckets }}

-- creating policy bindings for {{ .name }}
EXEC google.storage.buckets.setIamPolicy
@bucket = '{{ .name }}'
@@json = '{
"bindings": {{ .data }}
}';
{{ end }}

/*** create role bindings for topics in stackql-audit */ {{ range $root.bindings.topics }}

-- creating policy bindings for {{ .name }}
EXEC google.pubsub.`projects.topics`.setIamPolicy
@resource = '{{ .resource }}'
@@json = '{
"bindings": {{ .data }}
}';
{{ end }}

data/iam.jsonnet

This file is preprocessed by iql/iam.iql sourcing data from data/iam/bindings.json

this file should not need to be modified

Expand to see the data/iam.jsonnet file
// update the following files only:
// data/iam/custom_roles.json
// data/iam/bindings.json

// variables
local custom_roles_data = import './data/iam/custom_roles.json';
local bindings_data = import './data/iam/bindings.json';
local organization_id = 'organizations/123466304837';
local billing_account_id = 'billingAccounts/123456-9DFD97-EE2B33';
local nonprod_folder_id = 'folders/1234016945998';
local prod_folder_id = 'folders/123431606453';
local datalabs_folder_id = 'folders/123464988355';
local environments = [{name: 'prod'}, {name: 'nonprod'}, {name: 'datalabs'}];

// DO NOT MODIFY BEYOND THIS POINT

local generate_binding(x) =
local members = [x for x in x.members];
std.map(function(r) {"role": r, "members": members} , [x for x in x.roles]);

local generate_conditional_binding(x) =
local members = [x for x in x.members];
local condition = x.condition;
std.map(function(r) {"role": r, "members": members, "condition": condition} , [x for x in x.roles]);

{
organization_id: organization_id,
billing_account_id: billing_account_id,
nonprod_folder_id: nonprod_folder_id,
prod_folder_id: prod_folder_id,
datalabs_folder_id: datalabs_folder_id,
custom_roles: custom_roles_data,
environments: environments,
bindings: {
org: std.flattenArrays(std.map(generate_binding, bindings_data.org)),
billingacct: std.flattenArrays(std.map(generate_binding, bindings_data.billingacct)),
folders: {
nonprod: std.flattenArrays(std.map(generate_binding, bindings_data.folders.nonprod)),
prod: std.flattenArrays(std.map(generate_binding, bindings_data.folders.prod)),
datalabs: std.flattenArrays(std.map(generate_binding, bindings_data.folders.datalabs)),
},
projects: {
stackql_audit: std.flattenArrays(std.map(generate_conditional_binding, bindings_data.projects.stackql_audit)),
},
buckets: [
{
name: "stackql-tf-prod",
data: std.flattenArrays(std.map(generate_binding, bindings_data.buckets.stackql_tf_prod)),
},
{
name: "stackql-tf-modules-prod",
data: std.flattenArrays(std.map(generate_binding, bindings_data.buckets.stackql_tf_modules_prod)),
},
{
name: "stackql-tf-nonprod",
data: std.flattenArrays(std.map(generate_binding, bindings_data.buckets.stackql_tf_nonprod)),
},
{
name: "stackql-tf-modules-nonprod",
data: std.flattenArrays(std.map(generate_binding, bindings_data.buckets.stackql_tf_modules_nonprod)),
},
{
name: "stackql-tf-datalabs",
data: std.flattenArrays(std.map(generate_binding, bindings_data.buckets.stackql_tf_datalabs)),
},
{
name: "stackql-tf-modules-datalabs",
data: std.flattenArrays(std.map(generate_binding, bindings_data.buckets.stackql_tf_modules_datalabs)),
},
],
topics: [
{
name: "stackql-np-log-topic",
resource: "projects/stackql-audit/topics/stackql-np-log-topic",
data: std.flattenArrays(std.map(generate_binding, bindings_data.topics.stackql_np_log_topic)),
},
{
name: "stackql-prod-log-topic",
resource: "projects/stackql-audit/topics/stackql-prod-log-topic",
data: std.flattenArrays(std.map(generate_binding, bindings_data.topics.stackql_prod_log_topic)),
},
],
},
}

data/iam/bindings.json

this file will need to be modified to make policy binding changes

This is the master source of policy and binding data, the data structure maps one or many principals (groups, serviceaccount, etc) to one or many roles (predefined or custom) at a particular level (parent) as shown below:

  "<< level >>": [
{
"members": [
"<< principal >>",
...
],
"roles": [
"<< role >>",
...
]
},

for example:

  "folders": {
"nonprod": [
{
"members": [
"serviceAccount:terraform-nonprod@stackql-terraform.iam.gserviceaccount.com"
],
"roles": [
"roles/compute.networkAdmin",
"roles/resourcemanager.folderViewer",
"roles/resourcemanager.projectCreator",
"roles/storage.admin",
"roles/artifactregistry.admin",
"roles/container.serviceAgent",
"roles/iam.securityAdmin",
"roles/bigquery.admin",
"roles/serviceusage.serviceUsageAdmin",
"roles/cloudsql.admin",
"roles/iam.serviceAccountAdmin",
"roles/pubsub.admin",
"roles/cloudfunctions.developer"
]
}
],

Deployment

This process is intentionally not run through a CI/CD pipeline, as the changes are infrequent, and we wish not to give a service account the levels of priveleges required to deploy this. This is to be run by a priveleged adminstrator who is a member of the gcp-org-administrators Google group.

The user needs to be authenticated to GCP, using either a service account or interactive authentication, for more information about authenticating StackQL to Google see GCP Authentication.

Run the following command to perform a dryrun:

stackql exec -i ./iql/iam.iql \
--iqldata ./data/iam.jsonnet \
--outfile iam-TEMPLATED.iql \
--dryrun --output text --hideheaders

The resultant data in this file can be exectuted as individual statements in stackql shell or as a batch by running:

stackql exec -i iam-TEMPLATED.iql \

Alternatively, you could take the policies (JSON payloads) generated by this script and run them using the gcloud iam command.