diff --git a/lib/controller-inventory.mjs b/lib/controller-inventory.mjs index 50c224a..b4cf3b6 100644 --- a/lib/controller-inventory.mjs +++ b/lib/controller-inventory.mjs @@ -2,8 +2,21 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; -export function resourceKey({ project, kind, id }) { - return `${project}\u0000${kind}\u0000${id}`; +export function iamConditionKey(condition) { + if (!condition) return ''; + const normalize = (value) => typeof value === 'string' + ? value.trim().replace(/\s+/g, ' ') + : ''; + return JSON.stringify({ + title: normalize(condition.title), + description: normalize(condition.description), + expression: normalize(condition.expression), + }); +} + +export function resourceKey({ project, kind, id, condition }) { + const conditionKey = kind === 'IAMPolicyMember' ? iamConditionKey(condition) : ''; + return `${project}\u0000${kind}\u0000${id}\u0000${conditionKey}`; } function requireString(value, field, index, path) { diff --git a/lib/get-resources.mjs b/lib/get-resources.mjs index 6d1bf35..236bb25 100644 --- a/lib/get-resources.mjs +++ b/lib/get-resources.mjs @@ -295,19 +295,26 @@ export function replaceBucketIamPolicies(searchEntries, bucketAssets) { ]; } -export function liveIamPolicyIds(iamPolicies, project, includeSystem = false) { - const liveIam = []; +export function liveIamPolicyResources(iamPolicies, project, includeSystem = false) { + const resources = []; 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}`); + resources.push({ + id: `${rid}/${binding.role}/${member}`, + ...(binding.condition ? { condition: binding.condition } : {}), + }); } } } } - return liveIam; + return resources; +} + +export function liveIamPolicyIds(iamPolicies, project, includeSystem = false) { + return liveIamPolicyResources(iamPolicies, project, includeSystem).map((resource) => resource.id); } function kccArId(ref) { @@ -387,8 +394,11 @@ export async function collectNamespace(namespace, { includeSystem = false } = {} const resources = []; const diagnostics = []; - const push = (kind, ids, source) => { - for (const id of ids) if (id) resources.push({ kind, id, source }); + const push = (kind, entries, source) => { + for (const entry of entries) { + if (typeof entry === 'string' && entry) resources.push({ kind, id: entry, source }); + else if (entry && entry.id) resources.push({ kind, ...entry, source }); + } }; const assets = await searchAllResources(project); @@ -706,7 +716,7 @@ export async function collectNamespace(namespace, { includeSystem = false } = {} listBucketIamPolicyAssets(project), ]); const iamPolicies = replaceBucketIamPolicies(searchedIamPolicies, bucketIamPolicyAssets); - push('IAMPolicyMember', liveIamPolicyIds(iamPolicies, project, includeSystem), 'gcp'); + push('IAMPolicyMember', liveIamPolicyResources(iamPolicies, project, includeSystem), 'gcp'); const runServiceRefs = new Map(runServiceItems.map((i) => [i.metadata && i.metadata.name, kccScopedId(i)])); push('IAMPolicyMember', await kccField( @@ -714,7 +724,10 @@ export async function collectNamespace(namespace, { includeSystem = false } = {} (item) => { const rid = kccPolicyMemberId(item, project, runServiceRefs); const spec = item.spec || {}; - return `${rid}/${spec.role}/${spec.member}`; + return { + id: `${rid}/${spec.role}/${spec.member}`, + ...(spec.condition ? { condition: spec.condition } : {}), + }; }, ), 'kcc'); diff --git a/lib/kcc-inventory.mjs b/lib/kcc-inventory.mjs index cae6cbc..6b8261b 100644 --- a/lib/kcc-inventory.mjs +++ b/lib/kcc-inventory.mjs @@ -141,7 +141,10 @@ function diffAndReport(collected, externalDeclarations, externalDiagnostics, jso console.log(`== ${kind} (проєкт ${project}) ==`); for (const entry of visible.sort((a, b) => a.id.localeCompare(b.id))) { const owner = entry.owner ? ` (${entry.owner})` : ''; - console.log(` ${entry.status.toUpperCase()}${owner} — ${entry.id}`); + const condition = entry.condition + ? ` [condition: ${entry.condition.title || entry.condition.expression}]` + : ''; + console.log(` ${entry.status.toUpperCase()}${owner} — ${entry.id}${condition}`); } console.log(''); } diff --git a/test/controller-inventory.test.mjs b/test/controller-inventory.test.mjs index b9e6416..15b0a2b 100644 --- a/test/controller-inventory.test.mjs +++ b/test/controller-inventory.test.mjs @@ -36,6 +36,46 @@ test('reports controller-specific orphans', () => { assert.equal(result.status, 'orphan_opentofu'); }); +test('keeps IAM bindings with distinct conditions as separate resources', () => { + const base = { + project: 'nitraai', + kind: 'IAMPolicyMember', + id: 'project/nitraai/roles/iam.workloadIdentityPoolAdmin/serviceAccount:kcc-nitraai@nitraai.iam.gserviceaccount.com', + }; + const live = [ + { ...base, condition: { title: 'provider-only', expression: 'resource.name == provider' } }, + { ...base, condition: { title: 'pool-and-provider', expression: 'resource.name == pool || resource.name == provider' } }, + ]; + + const results = classifyResources(live, []); + + assert.equal(results.length, 2); + assert.deepEqual(results.map((resource) => resource.condition.title), [ + 'provider-only', + 'pool-and-provider', + ]); + assert.ok(results.every((resource) => resource.status === 'uncovered')); +}); + +test('normalizes condition whitespace when matching KCC and live IAM bindings', () => { + const base = { + project: 'nitraai', + kind: 'IAMPolicyMember', + id: 'project/nitraai/roles/viewer/user:reader@example.com', + }; + const [result] = classifyResources( + [{ ...base, condition: { title: 'scoped', expression: 'resource.name == provider' } }], + [{ + ...base, + controller: 'kcc', + source: 'nitraai', + condition: { title: ' scoped ', expression: 'resource.name == provider' }, + }], + ); + + assert.equal(result.status, 'covered_kcc'); +}); + test('rejects overlapping declarations instead of choosing a controller by precedence', () => { assert.throws(() => classifyResources([pool], [ { ...pool, controller: 'kcc', source: 'nitraai' }, diff --git a/test/iam-policy-inventory.test.mjs b/test/iam-policy-inventory.test.mjs index 4918198..4ab7d8c 100644 --- a/test/iam-policy-inventory.test.mjs +++ b/test/iam-policy-inventory.test.mjs @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { liveIamPolicyIds, replaceBucketIamPolicies } from '../lib/get-resources.mjs'; +import { + liveIamPolicyIds, + liveIamPolicyResources, + replaceBucketIamPolicies, +} from '../lib/get-resources.mjs'; const searchEntries = [ { @@ -90,3 +94,23 @@ test('keeps legacy bucket bindings when system resources are explicitly included 'bucket/missing-from-search/roles/storage.legacyBucketOwner/projectOwner:nitraai', )); }); + +test('preserves IAM conditions alongside the canonical binding ID', () => { + const resources = liveIamPolicyResources([{ + assetType: 'cloudresourcemanager.googleapis.com/Project', + resource: '//cloudresourcemanager.googleapis.com/projects/nitraai', + policy: { + bindings: [{ + role: 'roles/iam.workloadIdentityPoolAdmin', + members: ['serviceAccount:kcc-nitraai@nitraai.iam.gserviceaccount.com'], + condition: { + title: 'provider-only', + expression: 'resource.name == provider', + }, + }], + }, + }], 'nitraai'); + + assert.equal(resources[0].condition.title, 'provider-only'); + assert.match(resources[0].id, /roles\/iam\.workloadIdentityPoolAdmin/); +});