diff --git a/lib/get-resources.mjs b/lib/get-resources.mjs index d5ca1c4..6d1bf35 100644 --- a/lib/get-resources.mjs +++ b/lib/get-resources.mjs @@ -65,6 +65,18 @@ function searchAllIamPolicies(project) { return paginate(`${ASSET_API}/projects/${project}:searchAllIamPolicies`, { pageSize: 500 }, 'results'); } +function listBucketIamPolicyAssets(project) { + return paginate( + `${ASSET_API}/projects/${project}/assets`, + { + contentType: 'IAM_POLICY', + assetTypes: ['storage.googleapis.com/Bucket'], + pageSize: 500, + }, + 'assets', + ); +} + // projects/-/serviceAccounts/{email} — валідний шлях в IAM API, project // беремо з листа `-`: не треба окремо передавати project поруч з email. async function listUserManagedKeyIds(email) { @@ -272,6 +284,32 @@ function assetResId(entry, project) { return 'other/' + stripPrefix(res, '//'); } +export function replaceBucketIamPolicies(searchEntries, bucketAssets) { + return [ + ...searchEntries.filter((entry) => entry.assetType !== 'storage.googleapis.com/Bucket'), + ...bucketAssets.map((asset) => ({ + assetType: asset.assetType, + resource: asset.name, + policy: asset.iamPolicy, + })), + ]; +} + +export function liveIamPolicyIds(iamPolicies, project, includeSystem = false) { + const liveIam = []; + for (const entry of iamPolicies) { + const rid = assetResId(entry, project); + for (const binding of (entry.policy && entry.policy.bindings) || []) { + for (const member of binding.members || []) { + if (includeSystem || !isSystem(IAM_SYSTEM, member)) { + liveIam.push(`${rid}/${binding.role}/${member}`); + } + } + } + } + return liveIam; +} + function kccArId(ref) { const ext = ref.external || ''; const m = ext.match(/\/repositories\/([^/]+)$/); @@ -659,19 +697,16 @@ export async function collectNamespace(namespace, { includeSystem = false } = {} // --- IAMPolicyMember --------------------------------------------------------- // search-all-iam-policies — усі біндинги проєкту одразу, на будь-якому // типі ресурсу, не тільки на трьох раніше підтримуваних (Project/SA/AR). - const iamPolicies = await searchAllIamPolicies(project); - const liveIam = []; - for (const entry of iamPolicies) { - const rid = assetResId(entry, project); - for (const binding of (entry.policy && entry.policy.bindings) || []) { - for (const member of binding.members || []) { - if (includeSystem || !isSystem(IAM_SYSTEM, member)) { - liveIam.push(`${rid}/${binding.role}/${member}`); - } - } - } - } - push('IAMPolicyMember', liveIam, 'gcp'); + // searchAllIamPolicies uses a separate eventually-consistent search index + // which can omit bucket policies that assets.list already exposes. Keep the + // broad search for other resource types, but replace its bucket slice with + // the complete IAM_POLICY asset snapshot. + const [searchedIamPolicies, bucketIamPolicyAssets] = await Promise.all([ + searchAllIamPolicies(project), + listBucketIamPolicyAssets(project), + ]); + const iamPolicies = replaceBucketIamPolicies(searchedIamPolicies, bucketIamPolicyAssets); + push('IAMPolicyMember', liveIamPolicyIds(iamPolicies, project, includeSystem), 'gcp'); const runServiceRefs = new Map(runServiceItems.map((i) => [i.metadata && i.metadata.name, kccScopedId(i)])); push('IAMPolicyMember', await kccField( diff --git a/test/iam-policy-inventory.test.mjs b/test/iam-policy-inventory.test.mjs new file mode 100644 index 0000000..4918198 --- /dev/null +++ b/test/iam-policy-inventory.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { liveIamPolicyIds, replaceBucketIamPolicies } from '../lib/get-resources.mjs'; + +const searchEntries = [ + { + assetType: 'cloudresourcemanager.googleapis.com/Project', + resource: '//cloudresourcemanager.googleapis.com/projects/nitraai', + policy: { + bindings: [{ + role: 'roles/viewer', + members: ['user:reader@example.com'], + }], + }, + }, + { + assetType: 'storage.googleapis.com/Bucket', + resource: '//storage.googleapis.com/indexed-bucket', + policy: { + bindings: [{ + role: 'roles/storage.objectViewer', + members: ['user:indexed@example.com'], + }], + }, + }, +]; + +const bucketAssets = [ + { + assetType: 'storage.googleapis.com/Bucket', + name: '//storage.googleapis.com/indexed-bucket', + iamPolicy: { + bindings: [{ + role: 'roles/storage.objectViewer', + members: ['user:indexed@example.com'], + }], + }, + }, + { + assetType: 'storage.googleapis.com/Bucket', + name: '//storage.googleapis.com/missing-from-search', + iamPolicy: { + bindings: [ + { + role: 'roles/storage.legacyBucketOwner', + members: ['projectOwner:nitraai'], + }, + { + role: 'roles/storage.objectAdmin', + members: ['serviceAccount:writer@nitraai.iam.gserviceaccount.com'], + }, + { + role: 'roles/storage.objectViewer', + members: ['allUsers'], + }, + ], + }, + }, +]; + +test('replaces the incomplete bucket search slice with IAM_POLICY assets', () => { + const entries = replaceBucketIamPolicies(searchEntries, bucketAssets); + const bucketEntries = entries.filter((entry) => entry.assetType === 'storage.googleapis.com/Bucket'); + + assert.deepEqual(bucketEntries.map((entry) => entry.resource), [ + '//storage.googleapis.com/indexed-bucket', + '//storage.googleapis.com/missing-from-search', + ]); + assert.equal(entries.filter((entry) => entry.assetType === 'cloudresourcemanager.googleapis.com/Project').length, 1); +}); + +test('covers bucket service-account and public bindings while filtering legacy ACL noise', () => { + const entries = replaceBucketIamPolicies(searchEntries, bucketAssets); + const ids = liveIamPolicyIds(entries, 'nitraai'); + + assert.ok(ids.includes( + 'bucket/missing-from-search/roles/storage.objectAdmin/serviceAccount:writer@nitraai.iam.gserviceaccount.com', + )); + assert.ok(ids.includes( + 'bucket/missing-from-search/roles/storage.objectViewer/allUsers', + )); + assert.ok(!ids.some((id) => id.includes('projectOwner:nitraai'))); +}); + +test('keeps legacy bucket bindings when system resources are explicitly included', () => { + const entries = replaceBucketIamPolicies(searchEntries, bucketAssets); + const ids = liveIamPolicyIds(entries, 'nitraai', true); + + assert.ok(ids.includes( + 'bucket/missing-from-search/roles/storage.legacyBucketOwner/projectOwner:nitraai', + )); +});