Skip to content

Commit 057fe7b

Browse files
authored
Enforce dedicated service account for Cloud Build triggers (#110)
* feat: enforce mandatory SA, slim down roles, and avoid using compute engine default SA * feat: implement minimum required permissions for Cloud Build SA and Service Agents
1 parent 86f140d commit 057fe7b

File tree

4 files changed

+147
-40
lines changed

4 files changed

+147
-40
lines changed

cicd-mcp-server/cloudbuild/cloudbuild.go

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ type CreateTriggerArgs struct {
9191
Location string `json:"location" jsonschema:"The Google Cloud location for the trigger."`
9292
TriggerID string `json:"trigger_id" jsonschema:"The ID of the trigger."`
9393
RepoLink string `json:"repo_link" jsonschema:"The Developer Connect repository link, use dev connect setup repo to create a connect and repo link"`
94-
ServiceAccount string `json:"service_account,omitempty" jsonschema:"The service account to use for the build. E.g. serviceAccount:name@project-id.iam.gserviceaccount.com optional"`
94+
ServiceAccount string `json:"service_account" jsonschema:"The service account to use for the build. E.g. serviceAccount:name@project-id.iam.gserviceaccount.com. This MUST be a dedicated service account, not the default Compute Engine service account."`
9595
Branch string `json:"branch,omitempty" jsonschema:"Create builds on push to branch. Should be regex e.g. '^main$'"`
9696
Tag string `json:"tag,omitempty" jsonschema:"Create builds on new tag push. Should be regex e.g. '^nightly$'"`
9797
}
@@ -100,10 +100,13 @@ var createTriggerToolFunc func(ctx context.Context, req *mcp.CallToolRequest, ar
100100

101101
func addCreateTriggerTool(server *mcp.Server, cbClient cloudbuildclient.CloudBuildClient, iamClient iamclient.IAMClient, rmClient resourcemanagerclient.ResourcemanagerClient) {
102102
createTriggerToolFunc = func(ctx context.Context, req *mcp.CallToolRequest, args CreateTriggerArgs) (*mcp.CallToolResult, any, error) {
103-
if args.ServiceAccount != "" && !strings.HasPrefix(args.ServiceAccount, "serviceAccount:") {
103+
if args.ServiceAccount == "" {
104+
return &mcp.CallToolResult{}, nil, fmt.Errorf("service_account is required and must be a dedicated service account")
105+
}
106+
if !strings.HasPrefix(args.ServiceAccount, "serviceAccount:") {
104107
args.ServiceAccount = fmt.Sprintf("serviceAccount:%s", args.ServiceAccount)
105108
}
106-
if args.ServiceAccount != "" && !IsValidServiceAccount(args.ServiceAccount) {
109+
if !IsValidServiceAccount(args.ServiceAccount) {
107110
return &mcp.CallToolResult{}, nil, fmt.Errorf("service account needs to be of the form serviceAccount:name@project-id.iam.gserviceaccount.com")
108111
}
109112
resolvedSA, err := setPermissionsForCloudBuildSA(ctx, args.ProjectID, args.ServiceAccount, rmClient, iamClient)
@@ -119,30 +122,66 @@ func addCreateTriggerTool(server *mcp.Server, cbClient cloudbuildclient.CloudBui
119122
mcp.AddTool(server, &mcp.Tool{Name: "create_build_trigger", Description: "Creates a new Cloud Build trigger."}, createTriggerToolFunc)
120123
}
121124

122-
// setPermissionsForSA resolves the SA (if default) and grants it a role.
123-
// It creates and manages its own Resource Manager client.
125+
// setPermissionsForCloudBuildSA resolves the SA and grants it a role.
124126
func setPermissionsForCloudBuildSA(ctx context.Context, projectID, serviceAccount string, rmClient resourcemanagerclient.ResourcemanagerClient, iamClient iamclient.IAMClient) (string, error) {
125-
// Construct the Compute Engine default service account email
126127
resolvedSA := serviceAccount
127-
if resolvedSA == "" {
128-
projectNumber, err := rmClient.ToProjectNumber(ctx, projectID)
129-
if err != nil {
130-
return "", fmt.Errorf("unable to resolve project id to number: %w", err)
131-
}
132-
resolvedSA = fmt.Sprintf("serviceAccount:%d-compute@developer.gserviceaccount.com", projectNumber)
133-
}
134128

135129
// If the serviceAccount prefix is not there, add it.
136130
if !strings.HasPrefix(resolvedSA, "serviceAccount:") {
137131
resolvedSA = fmt.Sprintf("serviceAccount:%s", resolvedSA)
138132
}
139-
roles := []string{"roles/developerconnect.tokenAccessor"}
140-
for _, r := range roles {
133+
134+
// 1. Roles for the Cloud Build Service Account (Project Level)
135+
gcbSARoles := []string{
136+
"roles/artifactregistry.writer",
137+
"roles/cloudbuild.builds.editor",
138+
"roles/cloudbuild.workerpools.use",
139+
"roles/developerconnect.tokenAccessor",
140+
"roles/logging.logWriter",
141+
"roles/run.developer",
142+
"roles/serviceusage.serviceUsageConsumer",
143+
"roles/storage.admin",
144+
}
145+
for _, r := range gcbSARoles {
141146
_, err := iamClient.AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), r, resolvedSA)
142147
if err != nil {
143-
return "", fmt.Errorf("unable to add role %s to member %s on resource %s err: %w", r, resolvedSA, fmt.Sprintf("projects/%s", projectID), err)
148+
return "", fmt.Errorf("unable to add role %s to member %s on project %s err: %w", r, resolvedSA, projectID, err)
149+
}
150+
}
151+
152+
projectNumber, err := rmClient.ToProjectNumber(ctx, projectID)
153+
if err != nil {
154+
return "", fmt.Errorf("unable to resolve project id to number: %w", err)
155+
}
156+
157+
// 2. Roles for the Developer Connect Service Agent (Project Level)
158+
dcP4sa := fmt.Sprintf("serviceAccount:service-%d@gcp-sa-developerconnect.iam.gserviceaccount.com", projectNumber)
159+
_, err = iamClient.AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), "roles/secretmanager.admin", dcP4sa)
160+
if err != nil {
161+
return "", fmt.Errorf("unable to add secretmanager.admin role to Developer Connect P4SA %s: %w", dcP4sa, err)
162+
}
163+
164+
// 3. Roles for the Cloud Build Service Agent (Project Level)
165+
gcbP4sa := fmt.Sprintf("serviceAccount:service-%d@gcp-sa-cloudbuild.iam.gserviceaccount.com", projectNumber)
166+
gcbP4saRoles := []string{
167+
"roles/cloudbuild.serviceAgent",
168+
"roles/developerconnect.tokenAccessor",
169+
}
170+
for _, r := range gcbP4saRoles {
171+
_, err := iamClient.AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), r, gcbP4sa)
172+
if err != nil {
173+
return "", fmt.Errorf("unable to add role %s to Cloud Build P4SA %s on project %s err: %w", r, gcbP4sa, projectID, err)
144174
}
145175
}
176+
177+
// 4. Role for the Cloud Build Service Agent on the Cloud Run SA (Default Compute SA)
178+
defaultComputeSA := fmt.Sprintf("serviceAccount:%d-compute@developer.gserviceaccount.com", projectNumber)
179+
resource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, strings.TrimPrefix(defaultComputeSA, "serviceAccount:"))
180+
_, err = iamClient.AddIAMRoleBinding(ctx, resource, "roles/iam.serviceAccountUser", gcbP4sa)
181+
if err != nil {
182+
return "", fmt.Errorf("unable to add iam.serviceAccountUser role to Cloud Build P4SA %s on SA %s err: %w", gcbP4sa, defaultComputeSA, err)
183+
}
184+
146185
return resolvedSA, nil
147186
}
148187

cicd-mcp-server/cloudbuild/cloudbuild_test.go

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,32 +38,65 @@ func TestSetPermissionsForCloudBuildSA(t *testing.T) {
3838
serviceAccount := "serviceAccount:test-sa@example.com"
3939
serviceAccountWOPrefix := "test-sa@example.com"
4040

41+
projectNumber := int64(12345)
42+
gcbSARoles := []string{
43+
"roles/artifactregistry.writer",
44+
"roles/cloudbuild.builds.editor",
45+
"roles/cloudbuild.workerpools.use",
46+
"roles/developerconnect.tokenAccessor",
47+
"roles/logging.logWriter",
48+
"roles/run.developer",
49+
"roles/serviceusage.serviceUsageConsumer",
50+
"roles/storage.admin",
51+
}
52+
dcP4sa := fmt.Sprintf("serviceAccount:service-%d@gcp-sa-developerconnect.iam.gserviceaccount.com", projectNumber)
53+
gcbP4sa := fmt.Sprintf("serviceAccount:service-%d@gcp-sa-cloudbuild.iam.gserviceaccount.com", projectNumber)
54+
gcbP4saRoles := []string{
55+
"roles/cloudbuild.serviceAgent",
56+
"roles/developerconnect.tokenAccessor",
57+
}
58+
defaultComputeSA := fmt.Sprintf("%d-compute@developer.gserviceaccount.com", projectNumber)
59+
saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, defaultComputeSA)
60+
4161
t.Run("with service account", func(t *testing.T) {
42-
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), "roles/developerconnect.tokenAccessor", serviceAccount).Return(nil, nil)
62+
for _, r := range gcbSARoles {
63+
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), r, serviceAccount).Return(nil, nil)
64+
}
65+
mockRMClient.EXPECT().ToProjectNumber(ctx, projectID).Return(projectNumber, nil)
66+
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), "roles/secretmanager.admin", dcP4sa).Return(nil, nil)
67+
for _, r := range gcbP4saRoles {
68+
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), r, gcbP4sa).Return(nil, nil)
69+
}
70+
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, saResource, "roles/iam.serviceAccountUser", gcbP4sa).Return(nil, nil)
4371

4472
resolvedSA, err := setPermissionsForCloudBuildSA(ctx, projectID, serviceAccount, mockRMClient, mockIAMClient)
4573
assert.NoError(t, err)
4674
assert.Equal(t, serviceAccount, resolvedSA)
4775
})
4876

4977
t.Run("with service account, no prefix", func(t *testing.T) {
50-
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), "roles/developerconnect.tokenAccessor", serviceAccount).Return(nil, nil)
78+
for _, r := range gcbSARoles {
79+
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), r, serviceAccount).Return(nil, nil)
80+
}
81+
mockRMClient.EXPECT().ToProjectNumber(ctx, projectID).Return(projectNumber, nil)
82+
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), "roles/secretmanager.admin", dcP4sa).Return(nil, nil)
83+
for _, r := range gcbP4saRoles {
84+
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), r, gcbP4sa).Return(nil, nil)
85+
}
86+
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, saResource, "roles/iam.serviceAccountUser", gcbP4sa).Return(nil, nil)
5187

5288
resolvedSA, err := setPermissionsForCloudBuildSA(ctx, projectID, serviceAccountWOPrefix, mockRMClient, mockIAMClient)
5389
assert.NoError(t, err)
5490
assert.Equal(t, serviceAccount, resolvedSA)
5591
})
5692

57-
t.Run("without service account", func(t *testing.T) {
58-
projectNumber := int64(12345)
59-
expectedSA := fmt.Sprintf("serviceAccount:%d-compute@developer.gserviceaccount.com", projectNumber)
60-
61-
mockRMClient.EXPECT().ToProjectNumber(ctx, projectID).Return(projectNumber, nil)
62-
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), "roles/developerconnect.tokenAccessor", expectedSA).Return(nil, nil)
93+
t.Run("no service account provided fails", func(t *testing.T) {
94+
// Since fallback is removed, providing an empty SA should result in an invalid SA prefix
95+
// and fail during IAM role binding.
96+
mockIAMClient.EXPECT().AddIAMRoleBinding(gomock.Any(), gomock.Any(), gomock.Any(), "serviceAccount:").Return(nil, fmt.Errorf("invalid member"))
6397

64-
resolvedSA, err := setPermissionsForCloudBuildSA(ctx, projectID, "", mockRMClient, mockIAMClient)
65-
assert.NoError(t, err)
66-
assert.Equal(t, expectedSA, resolvedSA)
98+
_, err := setPermissionsForCloudBuildSA(ctx, projectID, "", mockRMClient, mockIAMClient)
99+
assert.Error(t, err)
67100
})
68101

69102
t.Run("iam error", func(t *testing.T) {
@@ -74,9 +107,14 @@ func TestSetPermissionsForCloudBuildSA(t *testing.T) {
74107
})
75108

76109
t.Run("resourcemanager error", func(t *testing.T) {
77-
mockRMClient.EXPECT().ToProjectNumber(gomock.Any(), gomock.Any()).Return(int64(0), fmt.Errorf("some error"))
110+
// Mock success for the first set of roles
111+
for _, r := range gcbSARoles {
112+
mockIAMClient.EXPECT().AddIAMRoleBinding(ctx, fmt.Sprintf("projects/%s", projectID), r, serviceAccount).Return(nil, nil)
113+
}
114+
// Mock failure for ToProjectNumber
115+
mockRMClient.EXPECT().ToProjectNumber(ctx, projectID).Return(int64(0), fmt.Errorf("some error"))
78116

79-
_, err := setPermissionsForCloudBuildSA(ctx, projectID, "", mockRMClient, mockIAMClient)
117+
_, err := setPermissionsForCloudBuildSA(ctx, projectID, serviceAccount, mockRMClient, mockIAMClient)
80118
assert.Error(t, err)
81119
})
82120
}

cicd-mcp-server/iam/client/iamclient.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,32 @@ func (c *IAMClientImpl) AddIAMRoleBinding(ctx context.Context, resourceID, role,
8282
return nil, fmt.Errorf("failed to get iam policy: %v", err)
8383
}
8484

85-
policy.Bindings = append(policy.Bindings, &cloudresourcemanagerv1.Binding{
86-
Role: role,
87-
Members: []string{member},
88-
})
85+
// Find the binding for the specific role
86+
var binding *cloudresourcemanagerv1.Binding
87+
for _, b := range policy.Bindings {
88+
if b.Role == role {
89+
binding = b
90+
break
91+
}
92+
}
93+
94+
if binding != nil {
95+
// Check if the member already exists in the binding
96+
for _, m := range binding.Members {
97+
if m == member {
98+
// Member already exists, no need to update
99+
return policy, nil
100+
}
101+
}
102+
// Add member to existing binding
103+
binding.Members = append(binding.Members, member)
104+
} else {
105+
// Create new binding for the role
106+
policy.Bindings = append(policy.Bindings, &cloudresourcemanagerv1.Binding{
107+
Role: role,
108+
Members: []string{member},
109+
})
110+
}
89111

90112
setPolicyRequest := &cloudresourcemanagerv1.SetIamPolicyRequest{
91113
Policy: policy,

skills/google-cicd-pipeline-design/references/how_to_create_cloudbuild_trigger.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,24 @@ This document outlines the standard, idempotent procedure for creating a Google
44

55
---
66

7-
## ## Core Principle: Idempotency
7+
## Core Principle: Idempotency
88

99
Every step in this process must be **idempotent**. This means the agent must **always check if a resource already exists** before attempting to create it. This prevents errors and ensures the process can be run multiple times safely.
1010

1111
---
1212

13-
## ## Prerequisite Checklist
13+
## Prerequisite Checklist
1414

1515
The following dependencies must be satisfied in order before creating the trigger.
1616

17-
### ### 1. Ensure `cloudbuild.yaml` Exists
17+
### 1. Ensure `cloudbuild.yaml` Exists
1818

1919
The trigger needs a build configuration file to execute.
2020

2121
* **Action**: Check for a `cloudbuild.yaml` file at the root of the source repository.
2222
* **If it does not exist**: Generate one by translating the user-approved plan. The steps in the generated YAML must be a direct translation of the components defined in the plan's `stages` object. The specifics of the steps (e.g., using `pytest` vs. `mvn test`) should be informed by discovering the application archetype (e.g., by finding a `pyproject.toml` or `pom.xml`).
2323

24-
### ### 2. Ensure Artifact Registry Repository Exists
24+
### 2. Ensure Artifact Registry Repository Exists
2525

2626
The `cloudbuild.yaml` file will reference an Artifact Registry repository to push container images. This repository must exist before a build can succeed.
2727

@@ -30,7 +30,7 @@ The `cloudbuild.yaml` file will reference an Artifact Registry repository to pus
3030
* **Check** if this repository already exists in the target GCP project.
3131
* **If it does not exist**: Create it using the available tools.
3232

33-
### ### 3. Ensure Developer Connect and Repository Link Exist
33+
### 3. Ensure Developer Connect and Repository Link Exist
3434

3535
Cloud Build triggers connect to source code via Developer Connect. The entire connection and repository link must be in place.
3636

@@ -40,8 +40,16 @@ Cloud Build triggers connect to source code via Developer Connect. The entire co
4040
4. **Check for Repository Link**: Check if a repository link for that specific URI already exists within the Developer Connect connection.
4141
5. **Create Repository Link (if needed)**: If the link does not exist, create it. This link is the resource that the Cloud Build trigger will formally point to.
4242

43+
### 4. Ensure Dedicated Service Account Exists
44+
45+
Cloud Build triggers should **always** use a dedicated, user-managed service account instead of the default Compute Engine service account to follow the principle of least privilege. **CRITICAL: NEVER use the default Compute Engine service account (typically formatted as `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`), even if you observe existing triggers in the project using it.**
46+
47+
1. **Check for Service Account**: Check if a dedicated service account for Cloud Build (e.g., `cloud-build-runner@<PROJECT_ID>.iam.gserviceaccount.com`) already exists.
48+
2. **Create Service Account (if needed)**: If no dedicated service account exists, create one using `gcloud iam service-accounts create`.
49+
3. **Required Roles (Ensured Automatically)**: The `create_build_trigger` MCP tool will **automatically ensure** the necessary roles (e.g., `roles/logging.logWriter`, `roles/artifactregistry.writer`, `roles/developerconnect.tokenAccessor`, `roles/run.developer`, `roles/storage.admin`, `roles/serviceusage.serviceUsageConsumer`, `roles/cloudbuild.builds.editor`, `roles/cloudbuild.workerpools.use`, and specific IAM delegation on the default Compute SA) are granted to the relevant service accounts. You **do not** need to grant these permissions manually.
50+
4351
---
4452

45-
## ## Final Step: Creating the Trigger
53+
## Final Step: Creating the Trigger
4654

47-
Once all prerequisites are met, the agent can create the trigger itself using the available tools.
55+
Once all prerequisites are met, the agent can create the trigger itself using the `create_build_trigger` tool. **You MUST provide the email of the dedicated service account identified or created in Step 4 to the `service_account` parameter.**

0 commit comments

Comments
 (0)