Skip to content

fix(lwpreflight): credit GCP basic roles with storage permissions - #1858

Merged
lokesh-vadlamudi merged 2 commits into
mainfrom
lvadlamudi/cad-2290-gcp-basic-role-storage-perms
Sep 2, 2026
Merged

fix(lwpreflight): credit GCP basic roles with storage permissions#1858
lokesh-vadlamudi merged 2 commits into
mainfrom
lvadlamudi/cad-2290-gcp-basic-role-storage-perms

Conversation

@lokesh-vadlamudi

@lokesh-vadlamudi lokesh-vadlamudi commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

JIRA https://lacework.atlassian.net/browse/CAD-2290

GCP preflight rejected any caller whose access came from roles/owner, with five errors, on every Agentless onboarding attempt:

Required permission missing: storage.buckets.get
Required permission missing: storage.buckets.getIamPolicy
Required permission missing: storage.buckets.setIamPolicy
Required permission missing: storage.objects.delete
Required permission missing: storage.objects.list

Root cause

FetchPolicies expands each role bound to the caller via iam.Roles.Get(role).IncludedPermissions, and CheckPermissions looks for the required permission strings in that set. It never evaluates the caller's real access.

Those five requirements are bucket-scoped. roles/owner is project-scoped and genuinely does not list them:

$ gcloud iam roles describe roles/owner     # 13582 permissions
storage.buckets.get           absent
storage.buckets.getIamPolicy  absent
storage.buckets.setIamPolicy  absent
storage.objects.delete        absent
storage.objects.list          absent
storage.buckets.create        present     <- project-scoped ones are there

Cloud Storage grants the bucket-scoped ones separately. Every bucket created in a project gets default IAM bindings for the project convenience values, confirmed against a live bucket:

projectEditor:PROJECT, projectOwner:PROJECT  ->  roles/storage.legacyBucketOwner
projectViewer:PROJECT                        ->  roles/storage.legacyBucketReader

roles/storage.legacyBucketOwner contains all five. So an Owner can complete the deployment but can never pass the check, regardless of identity type or configuration. Agentless is the only integration whose requirements include bucket-scoped permissions, which is why Configuration and the Audit Log integrations onboard fine under the same identity.

Fix

Map the basic roles to the legacy storage role Cloud Storage implies, and expand it alongside any basic role bound to the caller. Binding collection is extracted into a pure rolesForCaller so it can be unit tested; role expansion now happens once per distinct role rather than inline.

Effect on the check, simulated against the live role definitions:

Scope Required Missing before Missing after
Project 54 5 0
Org 65 12 7

The seven remaining at org scope are resourcemanager.folders.* and resourcemanager.organizations.*, which roles/owner genuinely does not grant. Those are correctly reported and are out of scope here.

Second commit: request policy version 3 on the org path

fetchProjectPolicies has always asked for RequestedPolicyVersion: 3, but fetchOrgPolicies sent an empty GetIamPolicyRequest, which returns a version 1 policy.

IAM does not reject a v1 request against a policy that carries conditional bindings. It silently rewrites each conditional binding's role name and drops the condition, confirmed against a live project:

requestedPolicyVersion=1  ->  roles/browser_withcond_d6d85a888910b56287b5   condition: <none>
requestedPolicyVersion=3  ->  roles/browser                                 condition: request.time... 

So if the caller sat in a conditional binding on the organization, rolesForCaller collected the mangled name and the following iam.roles.get returned 404, failing the whole org preflight. Conditional bindings belonging to other principals were unaffected.

Asking for version 3 on both paths makes the org path see real role names. Pre-existing issue, included here because it is the same function and the org path is where a hard failure would otherwise hide behind this fix.

Known ceiling, unchanged by this PR: rolesForCaller does not inspect Binding.Condition, so a conditional grant is credited as if unconditional. That is how the project path has always behaved; this commit only makes the org path consistent with it.

Test plan

  • go test ./lwpreflight/gcp/... covers owner, editor, viewer, custom roles, multi-policy dedupe, and bindings belonging to other principals.
  • Verified the tests fail with the mapping removed.
  • Verified roles/owner and roles/storage.legacyBucketOwner contents, and the default bucket bindings, against live GCP.

Fixes CAD-2290.

Live verification

Run against abc-demo-project-123 in org 121868925203, as a caller holding roles/owner on the
project. That project also granted the caller roles/storage.admin, which contains all five
permissions and so masks the bug; it is the only role on the project that supplies them. It was
removed for the duration of the test and restored afterwards, leaving roles/owner as the caller's
sole source of storage access, which is the situation the ticket describes.

Same command, one binary per commit:

$ lacework preflight gcp --agentless --project-id abc-demo-project-123

32db85e6 (parent)    gcp_agentless: FAIL (5 issue(s))   exit 1
                       Required permission missing: storage.buckets.get
                       Required permission missing: storage.buckets.getIamPolicy
                       Required permission missing: storage.buckets.setIamPolicy
                       Required permission missing: storage.objects.delete
                       Required permission missing: storage.objects.list

b6614d20 (this PR)   gcp_agentless: OK                  exit 0

Both runs resolved the caller, walked the real three-policy ancestry (project, folder, org) and
discovered 30 Cloud Scheduler regions, so this is the whole pipeline and not a harness.

Driving rolesForCaller into a live iam.Roles.Get and then CheckPermissions, over policies
fetched by the production code paths:

Scope Bindings used Required Missing at 32db85e Missing at b6614d2
Project all of the caller's real bindings 54 5 0
Project the caller's roles/owner binding only 54 5 0
Org all of the caller's real bindings 66 32 32

Org scope is unchanged because this caller holds no basic role at the organization, so the mapping
never fires. That is the negative control: the change is inert for a caller without a basic role and
does not blanket-grant storage permissions to everyone.

Completing the evidence for the second commit: the mangled name a v1 request returns is not a role
that exists, so the iam.roles.get that follows it cannot succeed.

iam.roles.get roles/browser_withcond_d6d85a888910b56287b5  ->  404, role not found
iam.roles.get roles/browser                                ->  200, 6 permissions

Unit side: with basicRoleStorageRoles emptied, 6 of the 8 TestRolesForCaller cases fail. The two
that do not touch the mapping, custom roles and other principals, stay green in both states, so the
failures are the change's doing rather than test breakage.

The GCP preflight permission check expands every role bound to the caller
via roles.get and looks for the required permission strings inside
includedPermissions. Five of the Agentless requirements are bucket-scoped
(storage.buckets.get, storage.buckets.getIamPolicy,
storage.buckets.setIamPolicy, storage.objects.delete and
storage.objects.list) and so are absent from the project-scoped basic
roles. Any caller whose access came from roles/owner therefore failed
preflight, deterministically, even though the deployment itself succeeds.

Cloud Storage grants those permissions separately: every bucket created in
a project gets default IAM bindings for the projectOwner, projectEditor
and projectViewer convenience values, which map to
roles/storage.legacyBucketOwner and roles/storage.legacyBucketReader.
Expand that implied legacy role alongside any basic role bound to the
caller so the permission set reflects what the caller can actually do.

CAD-2290
@lokesh-vadlamudi lokesh-vadlamudi self-assigned this Sep 1, 2026
fetchProjectPolicies asks for RequestedPolicyVersion 3 but
fetchOrgPolicies sent an empty GetIamPolicyRequest, which returns a
version 1 policy. When the policy carries conditional bindings, IAM does
not reject a v1 request: it rewrites each conditional binding's role to
roles/<name>_withcond_<hash> and drops the condition. Verified against a
live project, where roles/browser came back as
roles/browser_withcond_d6d85a888910b56287b5.

So if the caller sat in a conditional binding, rolesForCaller collected
the mangled name and the following iam.roles.get returned 404, failing
the whole org preflight. Bindings belonging to other principals were
unaffected.

Ask for version 3 on both paths so the org path sees real role names,
matching what the project path already does.

CAD-2290
@lokesh-vadlamudi
lokesh-vadlamudi force-pushed the lvadlamudi/cad-2290-gcp-basic-role-storage-perms branch from 143b671 to b6614d2 Compare September 1, 2026 22:27
@lokesh-vadlamudi
lokesh-vadlamudi marked this pull request as ready for review September 2, 2026 15:23
@lokesh-vadlamudi
lokesh-vadlamudi requested a review from a team as a code owner September 2, 2026 15:23
@lokesh-vadlamudi
lokesh-vadlamudi enabled auto-merge (squash) September 2, 2026 15:23
@lokesh-vadlamudi
lokesh-vadlamudi merged commit 990fd4f into main Sep 2, 2026
13 checks passed
@lokesh-vadlamudi
lokesh-vadlamudi deleted the lvadlamudi/cad-2290-gcp-basic-role-storage-perms branch September 2, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants