Skip to content

Commit 77e32ea

Browse files
authored
fix(provisioning): canonicalize CloudFront OAI principals in S3 BucketPolicy drift readback (#874)
1 parent 5706c9b commit 77e32ea

7 files changed

Lines changed: 461 additions & 21 deletions

File tree

docs/_generated/integ-last-run.tsv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,4 @@ opensearch-domain-getatt 2026-06-14T10:54:56Z PASS 2000 verify.sh CC-API GetAtt
136136
s3-vectors 2026-06-14T13:39:25Z PASS 60 verify.sh Tags backfill + in-place Tags update() (TagResource/UntagResource) re-run after create dedup; 0 orphans
137137
dynamodb-ondemand 2026-06-14T14:54:16Z PASS 120 verify.sh BillingMode/ProvisionedThroughput in-place UPDATE + backfills; 0 orphans
138138
glue-update-hardening 2026-06-15T01:57:37Z PASS verify.sh rc ok, orph clean (re-run after review fix; deploy+update+destroy clean)
139-
s3-cloudfront 2026-06-15T02:31:20Z PASS verify.sh rc ok, orph clean (re-run after OriginGroups review fix; drift clean+detect, clean destroy)
139+
s3-cloudfront 2026-06-15T04:03:59Z PASS verify.sh rc ok, orph clean (#872 re-run after review fixes; bucket policy + distribution clean; clean destroy)

docs/changelog-cdkd.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ The CLAUDE.md `## Known Limitations` section retains the load-bearing summary
1616

1717
---
1818

19+
**Recently Implemented** (2026-06-15):
20+
21+
- ✅ **Fix: canonicalize CloudFront OAI grant principals in `S3BucketPolicyProvider.readCurrentState` (closes the phantom drift on an S3 BucketPolicy granting an OAI, issue [#872](https://github.com/go-to-k/cdkd/issues/872))** — `src/provisioning/providers/s3-bucket-policy-provider.ts`. **Bug:** a bucket policy statement that grants a CloudFront Origin Access Identity (OAI) read access stores its principal as the OAI's S3 canonical user id (`{ CanonicalUser: <64-hex> }`, what CDK's `Fn::GetAtt [<OAI>, S3CanonicalUserId]` resolves to). But `s3:GetBucketPolicy` returns that same principal in TWO other unstable forms over the policy's lifetime: a transient `{ AWS: <IAM-unique-id> }` (e.g. `AIDA…`) right after `PutBucketPolicy`, then the settled `{ AWS: arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <id> }`. The drift comparator saw these three equivalent representations as different and fired a guaranteed false positive on every `cdkd drift` run for any stack with an OAI + S3 bucket policy (the deploy-time `observedProperties` capture caught the transient unique-id form; the later drift read got the ARN form — neither matched the template's canonical user id). **Fix:** `readCurrentState` now canonicalizes every recognizable OAI principal back to `{ CanonicalUser: <id> }`. For the settled `arn:…:cloudfront:user/…<oaiId>` form it maps the OAI id to its `S3CanonicalUserId`, **preferring the same-stack sibling OAI resource's already-read state attribute** (zero AWS call / no `cloudfront:GetCloudFrontOriginAccessIdentity` IAM grant — the OAI's `S3CanonicalUserId` is a readOnly attribute cdkd already resolved at deploy time, threaded into the drift read via the new `ReadCurrentStateContext.siblings[].attributes` + `.physicalId` fields) and falling back to `GetCloudFrontOriginAccessIdentity(<oaiId>)` only when the OAI is NOT a same-stack sibling (an imported / external OAI). The transient bare-IAM-unique-id form (which carries no recoverable link to the OAI) is canonicalized only by matching the corresponding template statement (same Effect / Action / Resource) carrying a `{ CanonicalUser }` principal — safe because a user cannot author a bare IAM unique id as a bucket-policy principal, so it only ever fires for AWS's transient rendering. Both the deploy-time capture and the drift read run through the same normalization, so both converge to the canonical user id and compare equal; a genuinely-different OAI resolves to a different canonical id so real drift is still detected (strict reconcile — never a blanket suppress). Non-OAI principals (`*`, service principals, normal role ARNs) are untouched. The `ReadCurrentStateContext.siblings` shape gained optional `physicalId` + `attributes` (populated by `buildReadCurrentStateContext` in `src/cli/commands/drift.ts`) so any provider can reconcile a sibling's computed identity from already-read state without an extra AWS call. 13 unit tests (ARN→CanonicalUser via sibling state attribute with NO CloudFront call; strict-reconcile: a DIFFERENT OAI resolves to a DIFFERENT canonical id so real drift is NOT suppressed; AWS-array principal + ambiguous-template-match left unchanged; ARN→CanonicalUser via `GetCloudFrontOriginAccessIdentity` fallback when not a sibling; bare-unique-id→template-match; no-template-match left unchanged; best-effort ARN-lookup-failure left unchanged; non-OAI principals untouched; one CloudFront call cached across statements). Validated against real AWS by extending the `s3-cloudfront` integ's `verify.sh` to assert the `AWS::S3::BucketPolicy` reports clean (in the `clean` drift bucket, not `drifted`) on a fresh deploy — the distribution-scoping caveat the CloudFront PR (#871) added is removed now that the bucket policy no longer phantom-drifts. (Approach mirrors the sibling cdk-real-drift project's fix per the #872 cross-note: read the OAI's `S3CanonicalUserId` from already-read state rather than re-fetching it.)
22+
1923
**Recently Implemented** (2026-06-14):
2024

2125
- ✅ **Fix: `AWS::DynamoDB::Table` in-place `BillingMode` / `ProvisionedThroughput` UPDATE (was a silent drop)** — `src/provisioning/providers/dynamodb-table-provider.ts`. **Bug:** `update()` handled OnDemandThroughput / WarmThroughput / PITR / TTL / ResourcePolicy / Kinesis / ContributorInsights / Tags but issued NO `UpdateTable` for `BillingMode` or `ProvisionedThroughput`, even though both are declared in `handledProperties` and both are mutable (CFn createOnly = only `TableName` + `ImportSourceSpecification`). So a pure capacity change (e.g. RCU 5→100, mode unchanged) or a pure billing-mode switch (PROVISIONED↔PAY_PER_REQUEST) was silently dropped — `update()` returned `{ wasReplaced: false }` with no AWS call while cdkd recorded the new value into state as if applied, so the next deploy saw no diff and the AWS-side capacity / mode stayed stale forever (the silent-drift failure mode documented in `feedback_tags_on_update_must_throw`). **Fix:** a new branch, ordered BEFORE the OnDemand/Warm throughput branches, fires a SINGLE `UpdateTable` whenever `BillingMode` OR `ProvisionedThroughput` changed. It forwards `BillingMode` when present and `ProvisionedThroughput` only when the (desired) mode is not `PAY_PER_REQUEST` (AWS rejects caps on on-demand; PROVISIONED requires them), coercing the string-typed capacity values to numbers via `Number()` (matching `create()`; CFn emits numerics as strings). A combined switch-to-PROVISIONED-with-caps now works because both fields ride one call before the OnDemand branch (closing the pre-existing fail-loud caveat the old comment documented). After the call it waits for ACTIVE via the existing `waitForTableActiveAfterUpdate` helper so later branches don't race a still-UPDATING table. Per-index `GlobalSecondaryIndexes` ProvisionedThroughput is explicitly NOT handled (a documented deferred gap, not a silent one — needs `GlobalSecondaryIndexUpdates`). 6 unit tests (pure capacity change PROVISIONED→PROVISIONED; string-numeric coercion; switch to PAY_PER_REQUEST drops caps; PAY_PER_REQUEST switch ignores stale template caps; switch to PROVISIONED sends both in one call; no-change makes no billing UpdateTable). Integ: the `dynamodb-ondemand` fixture gains a standalone PROVISIONED table whose capacity flips RCU 5→20 / WCU 5→10 under `CDKD_TEST_UPDATE=true`, and `verify.sh` adds a Phase-1.5 re-deploy + `describe-table` assertion that AWS reflects the new ProvisionedThroughput (plus a destroy-cleanup poll for the new table).

src/cli/commands/drift.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,13 +539,14 @@ export function buildReadCurrentStateContext(
539539
state: StackState,
540540
excludedLogicalId: string
541541
): ReadCurrentStateContext {
542-
const siblings: Record<string, { resourceType: string; properties: Record<string, unknown> }> =
543-
{};
542+
const siblings: NonNullable<ReadCurrentStateContext['siblings']> = {};
544543
for (const [lid, res] of Object.entries(state.resources ?? {})) {
545544
if (lid === excludedLogicalId) continue;
546545
siblings[lid] = {
547546
resourceType: res.resourceType,
547+
physicalId: res.physicalId,
548548
properties: res.properties ?? {},
549+
attributes: res.attributes ?? {},
549550
};
550551
}
551552
return { siblings };

src/provisioning/providers/s3-bucket-policy-provider.ts

Lines changed: 197 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,105 @@ import {
55
GetBucketPolicyCommand,
66
NoSuchBucket,
77
} from '@aws-sdk/client-s3';
8+
import { GetCloudFrontOriginAccessIdentityCommand } from '@aws-sdk/client-cloudfront';
89
import { getLogger } from '../../utils/logger.js';
910
import { getAwsClients } from '../../utils/aws-clients.js';
1011
import { ProvisioningError } from '../../utils/error-handler.js';
1112
import { assertRegionMatch, type DeleteContext } from '../region-check.js';
13+
14+
/**
15+
* Matches a CloudFront Origin Access Identity (OAI) principal ARN, e.g.
16+
* `arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E1UREC9EUJDVG5`.
17+
* The capture group is the OAI id, which resolves to the OAI's S3 canonical
18+
* user id via `GetCloudFrontOriginAccessIdentity`.
19+
*/
20+
const OAI_USER_ARN_RE =
21+
/^arn:aws[a-z-]*:iam::cloudfront:user\/CloudFront Origin Access Identity ([A-Z0-9]+)$/;
22+
23+
/**
24+
* Matches a bare IAM principal unique id (e.g. `AIDAIBJOSOJSBZ753XCAW`). S3
25+
* returns an OAI grant's principal in this transient form immediately after
26+
* `PutBucketPolicy` (before it settles to the friendly cloudfront-user ARN).
27+
* It carries no recoverable link back to the OAI, so it can only be
28+
* canonicalized by matching against the template's `{ CanonicalUser }` form.
29+
*/
30+
const IAM_UNIQUE_ID_RE = /^A[A-Z0-9]{15,}$/;
31+
32+
/**
33+
* Process-lifetime cache of OAI id -> S3 canonical user id (or `null` when the
34+
* lookup failed / the OAI is gone), so a `cdkd drift` run with many OAI-granting
35+
* bucket policies issues at most one `GetCloudFrontOriginAccessIdentity` per OAI.
36+
*/
37+
const oaiCanonicalUserIdCache = new Map<string, string | null>();
38+
39+
/** Test-only: reset the OAI canonical-user-id cache between unit tests. */
40+
export function clearOaiCanonicalUserIdCacheForTest(): void {
41+
oaiCanonicalUserIdCache.clear();
42+
}
43+
44+
/**
45+
* Build an `oaiId -> S3CanonicalUserId` map from the same-stack sibling
46+
* `AWS::CloudFront::CloudFrontOriginAccessIdentity` resources in the read
47+
* context. The OAI's `S3CanonicalUserId` is a readOnly attribute cdkd already
48+
* resolved at deploy time (the bucket-policy grant `Fn::GetAtt`s it), so this
49+
* lets the bucket-policy drift read reconcile the OAI principal forms with zero
50+
* extra AWS calls (and no `cloudfront:GetCloudFrontOriginAccessIdentity` IAM
51+
* grant) whenever the OAI lives in the same stack. The OAI resource's
52+
* `physicalId` IS the OAI id embedded in the cloudfront-user ARN.
53+
*/
54+
function buildSiblingOaiCanonicalMap(context?: ReadCurrentStateContext): Map<string, string> {
55+
const map = new Map<string, string>();
56+
for (const sib of Object.values(context?.siblings ?? {})) {
57+
if (sib.resourceType !== 'AWS::CloudFront::CloudFrontOriginAccessIdentity') continue;
58+
const canonical = sib.attributes?.['S3CanonicalUserId'];
59+
if (sib.physicalId && typeof canonical === 'string') {
60+
map.set(sib.physicalId, canonical);
61+
}
62+
}
63+
return map;
64+
}
65+
66+
/**
67+
* Pull the `Statement` array out of a parsed policy document, tolerating both
68+
* the single-object and array shapes (and a non-object input). Returns the live
69+
* statement objects so callers can mutate their `Principal` in place.
70+
*/
71+
function extractStatements(policyDoc: unknown): Record<string, unknown>[] {
72+
if (!policyDoc || typeof policyDoc !== 'object') return [];
73+
const stmt = (policyDoc as Record<string, unknown>)['Statement'];
74+
const arr = Array.isArray(stmt) ? stmt : stmt ? [stmt] : [];
75+
return arr.filter(
76+
(s): s is Record<string, unknown> => !!s && typeof s === 'object' && !Array.isArray(s)
77+
);
78+
}
79+
80+
/**
81+
* Find the `CanonicalUser` principal of the template statement that matches a
82+
* given AWS-side statement by Effect / Action / Resource, used to canonicalize
83+
* the unresolvable transient `{ AWS: <IAM-unique-id> }` OAI form. Returns
84+
* `undefined` when no single matching template statement carries one.
85+
*/
86+
function findTemplateCanonicalUser(
87+
awsStmt: Record<string, unknown>,
88+
templateStmts: Record<string, unknown>[]
89+
): string | undefined {
90+
const key = (s: Record<string, unknown>): string =>
91+
JSON.stringify([s['Effect'], s['Action'], s['Resource']]);
92+
const want = key(awsStmt);
93+
const matches = templateStmts.filter((t) => key(t) === want);
94+
if (matches.length !== 1) return undefined;
95+
const principal = matches[0]!['Principal'];
96+
if (!principal || typeof principal !== 'object' || Array.isArray(principal)) return undefined;
97+
const canonical = (principal as Record<string, unknown>)['CanonicalUser'];
98+
return typeof canonical === 'string' ? canonical : undefined;
99+
}
12100
import type {
13101
ResourceProvider,
14102
ResourceCreateResult,
15103
ResourceUpdateResult,
16104
ResourceImportInput,
17105
ResourceImportResult,
106+
ReadCurrentStateContext,
18107
} from '../../types/resource.js';
19108

20109
/**
@@ -238,7 +327,9 @@ export class S3BucketPolicyProvider implements ResourceProvider {
238327
async readCurrentState(
239328
physicalId: string,
240329
_logicalId: string,
241-
_resourceType: string
330+
_resourceType: string,
331+
properties?: Record<string, unknown>,
332+
context?: ReadCurrentStateContext
242333
): Promise<Record<string, unknown> | undefined> {
243334
let policyJson: string | undefined;
244335
try {
@@ -256,14 +347,118 @@ export class S3BucketPolicyProvider implements ResourceProvider {
256347
const result: Record<string, unknown> = {
257348
Bucket: physicalId,
258349
};
350+
let policyDoc: unknown;
259351
try {
260-
result['PolicyDocument'] = JSON.parse(policyJson) as unknown;
352+
policyDoc = JSON.parse(policyJson) as unknown;
261353
} catch {
262354
result['PolicyDocument'] = policyJson;
355+
return result;
263356
}
357+
358+
// Canonicalize CloudFront OAI grant principals (issue #872). AWS returns an
359+
// OAI grant's principal in THREE unstable forms over a policy's lifetime —
360+
// the template's `{ CanonicalUser: <64-hex> }`, a transient
361+
// `{ AWS: <IAM-unique-id> }` right after PutBucketPolicy, and the settled
362+
// `{ AWS: arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <id> }`.
363+
// The drift comparator sees these as different and fires a guaranteed false
364+
// positive. Normalize every recognizable OAI principal back to the canonical
365+
// user id form (matching what the template carries + what cdkd state holds).
366+
await this.normalizeOaiPrincipals(policyDoc, properties, context);
367+
result['PolicyDocument'] = policyDoc;
264368
return result;
265369
}
266370

371+
/**
372+
* Normalize CloudFront OAI grant principals in a (parsed) bucket policy to
373+
* the `{ CanonicalUser: <id> }` form, in place (issue #872):
374+
* - `{ AWS: <oai-user-arn> }` -> map the OAI id in the ARN to its
375+
* `S3CanonicalUserId`, preferring the sibling OAI resource's already-read
376+
* state attributes (zero AWS call — the OAI's `S3CanonicalUserId` is a
377+
* readOnly attribute cdkd already resolved at deploy time) and falling
378+
* back to `GetCloudFrontOriginAccessIdentity` only when the OAI is not a
379+
* same-stack sibling (e.g. an imported / external OAI). This is the SAFE
380+
* path: a genuinely-different OAI resolves to a different canonical id, so
381+
* real drift is still detected.
382+
* - `{ AWS: <bare-IAM-unique-id> }` -> the transient post-PutBucketPolicy
383+
* rendering, which carries no recoverable link to the OAI. Canonicalize
384+
* it only by matching the corresponding template statement (same Effect /
385+
* Action / Resource) carrying a `{ CanonicalUser }` principal. A user
386+
* cannot author a bare IAM unique id as a bucket-policy principal, so
387+
* adopting the template form here only fires for AWS's transient
388+
* rendering. NOTE: once the principal settles to the ARN form (seconds
389+
* to minutes), a genuine out-of-band OAI repoint IS detected via the ARN
390+
* path above; the only masking window is a repoint observed during the
391+
* transient bare-id phase, which the next drift run (ARN form) reports.
392+
* Statements whose principal is not a single-string `AWS` OAI form (e.g. a
393+
* wildcard, a service principal, a normal role ARN, or an `AWS` ARRAY of
394+
* principals) are left untouched — skipping is safe (it can only leave a
395+
* residual false positive, never hide real drift). CDK's single-OAI grant
396+
* emits the single-string form.
397+
*/
398+
private async normalizeOaiPrincipals(
399+
policyDoc: unknown,
400+
templateProps?: Record<string, unknown>,
401+
context?: ReadCurrentStateContext
402+
): Promise<void> {
403+
const stmts = extractStatements(policyDoc);
404+
if (stmts.length === 0) return;
405+
const templateStmts = extractStatements(templateProps?.['PolicyDocument']);
406+
const siblingCanonicalById = buildSiblingOaiCanonicalMap(context);
407+
408+
for (const stmt of stmts) {
409+
const principal = stmt['Principal'];
410+
if (!principal || typeof principal !== 'object' || Array.isArray(principal)) continue;
411+
const awsPrincipal = (principal as Record<string, unknown>)['AWS'];
412+
if (typeof awsPrincipal !== 'string') continue;
413+
414+
const arnMatch = OAI_USER_ARN_RE.exec(awsPrincipal);
415+
if (arnMatch) {
416+
const oaiId = arnMatch[1]!;
417+
// Prefer the sibling OAI's already-read state attribute; only call AWS
418+
// when the OAI is not a same-stack sibling.
419+
const canonical =
420+
siblingCanonicalById.get(oaiId) ?? (await this.resolveOaiCanonicalUserId(oaiId));
421+
if (canonical) stmt['Principal'] = { CanonicalUser: canonical };
422+
continue;
423+
}
424+
425+
if (IAM_UNIQUE_ID_RE.test(awsPrincipal)) {
426+
const tmplCanonical = findTemplateCanonicalUser(stmt, templateStmts);
427+
if (tmplCanonical) stmt['Principal'] = { CanonicalUser: tmplCanonical };
428+
}
429+
}
430+
}
431+
432+
/**
433+
* Resolve a CloudFront OAI id to its S3 canonical user id via
434+
* `GetCloudFrontOriginAccessIdentity`, cached for the process lifetime. Used
435+
* only as a FALLBACK when the OAI is not a same-stack sibling (its
436+
* `S3CanonicalUserId` is otherwise read straight from sibling state — see
437+
* {@link buildSiblingOaiCanonicalMap}). Best-effort: returns `null` (and
438+
* caches it) on any failure so a missing OAI / missing permission leaves the
439+
* principal unchanged rather than failing the drift read.
440+
*/
441+
private async resolveOaiCanonicalUserId(oaiId: string): Promise<string | null> {
442+
const cached = oaiCanonicalUserIdCache.get(oaiId);
443+
if (cached !== undefined) return cached;
444+
let canonical: string | null = null;
445+
try {
446+
const resp = await getAwsClients().cloudFront.send(
447+
new GetCloudFrontOriginAccessIdentityCommand({ Id: oaiId })
448+
);
449+
canonical = resp.CloudFrontOriginAccessIdentity?.S3CanonicalUserId ?? null;
450+
} catch (err) {
451+
this.logger.debug(
452+
`Could not resolve CloudFront OAI ${oaiId} canonical user id for bucket-policy drift normalization: ${
453+
err instanceof Error ? err.message : String(err)
454+
} — leaving the principal unchanged.`
455+
);
456+
canonical = null;
457+
}
458+
oaiCanonicalUserIdCache.set(oaiId, canonical);
459+
return canonical;
460+
}
461+
267462
/**
268463
* Adopt an existing S3 bucket policy into cdkd state.
269464
*

0 commit comments

Comments
 (0)