From 6fe70f1c2eeed70e5c276d0488da70180e248922 Mon Sep 17 00:00:00 2001 From: DongZhaoXiong <13228081023@163.com> Date: Mon, 14 Sep 2026 14:36:21 +0000 Subject: [PATCH] feat(deploy): support an IAM permissions boundary for project roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In an account whose CDK CloudFormation execution role carries an organization boundary that denies iam:CreateRole unless the new role carries a boundary itself, `agentcore deploy` could not succeed, and the failure gave the user nothing to act on. Accept a boundary from three sources, most specific first: AGENTCORE_PERMISSIONS_BOUNDARY, the project's `iam.permissionsBoundary` in agentcore.json, and `permissionsBoundary` in ~/.agentcore/config.json. The value is a policy name or a policy ARN; a bare name is resolved against each target's own partition and account, so one value works across accounts and partitions. A blank value reads as unset everywhere, which is how the machine default gets cleared — `agentcore config` can only write keys, never remove them. A boundary is a property of the account you deploy into, not of the project, so the machine config is usually the right home: set once, applies to every project, not committed, and it cannot break a teammate whose account has no such policy. It sits alongside the existing corporate-environment settings there (uvIndex, disableDependencyManagement). The project field is for teams that all deploy into the same enforcing account and want the constraint reviewed in git. No prompt was added: at `agentcore create` the user does not yet know which account they will deploy into, and the boundary is chosen by whoever set that account up. The boundary is applied as the `@aws-cdk/core:permissionsBoundary` context entry, layered onto the app's own context store. aws-cdk-lib turns that into a stack-wide aspect over AWS::IAM::Role / AWS::IAM::User, which reaches the roles created inside the @aws/agentcore-cdk L3 constructs without those constructs exposing a prop, and without changing the vended CDK project. Resolution lives in CdkToolkitWrapper.initialize() so synth, deploy, diff and destroy agree; a boundary applied on deploy but not on diff would read as permanent drift. The A/B test execution role is created through the IAM API, which cannot resolve a bare policy name, so that path expands the boundary to a full ARN first. Recognizing the failure needs the toolkit's message stream, not the thrown error. CloudFormation rolls the stack back and toolkit-lib then throws `NoStack: CloudFormationStack object does not hold a stack` with an empty cause; the IAM denial only ever appears on a CDK_TOOLKIT_I5502 progress message. So the ioHost is wrapped with a transparent pass-through that remembers the first boundary denial, and deploy() falls back to it, scoped per call so an earlier denial cannot be pinned on a later unrelated failure. Reading CloudFormation stack events instead was rejected: cloudformation:DescribeStackEvents is only in the CFN execution role policy, not the developer policy, so it would force admins to widen user permissions just to get an error message. Detection is narrow on purpose — iam:CreateRole denied by a boundary — since that is the case with this specific remedy. docs/PERMISSIONS.md described this configuration as blocked on construct changes; that note is replaced with the actual setup step plus a troubleshooting entry. docs/configuration.md gains an IAM Settings section and states the intent of the `iam` block as constraints imposed from outside the project, to keep it from drifting into a role-authoring surface. Deploy telemetry records whether a boundary was applied; the value itself is customer-identifying and is not emitted. Closes #2292 --- docs/PERMISSIONS.md | 52 ++- docs/configuration.md | 77 ++++- schemas/agentcore.schema.v1.json | 10 + .../__tests__/permissions-boundary.test.ts | 144 +++++++++ src/cli/aws/index.ts | 9 + src/cli/aws/permissions-boundary.ts | 102 ++++++ .../__tests__/permissions-boundary.test.ts | 296 ++++++++++++++++++ src/cli/cdk/permissions-boundary.ts | 131 ++++++++ .../cdk/toolkit-lib/__tests__/wrapper.test.ts | 203 ++++++++++++ src/cli/cdk/toolkit-lib/types.ts | 7 + src/cli/cdk/toolkit-lib/wrapper.ts | 71 ++++- .../commands/deploy/__tests__/utils.test.ts | 44 ++- src/cli/commands/deploy/utils.ts | 9 + src/cli/constants.ts | 19 ++ .../jobs/ab-test/__tests__/resolve.test.ts | 49 ++- src/cli/operations/jobs/ab-test/handler.ts | 1 + src/cli/operations/jobs/ab-test/resolve.ts | 14 + src/cli/telemetry/schemas/command-run.ts | 2 + src/cli/telemetry/schemas/common-shapes.ts | 1 + src/lib/errors/types.ts | 45 +++ src/lib/schemas/io/global-config.ts | 9 + src/schema/llm-compacted/agentcore.ts | 6 + .../__tests__/agentcore-project.test.ts | 40 +++ src/schema/schemas/agentcore-project.ts | 38 +++ 24 files changed, 1355 insertions(+), 24 deletions(-) create mode 100644 src/cli/aws/__tests__/permissions-boundary.test.ts create mode 100644 src/cli/aws/permissions-boundary.ts create mode 100644 src/cli/cdk/__tests__/permissions-boundary.test.ts create mode 100644 src/cli/cdk/permissions-boundary.ts create mode 100644 src/cli/cdk/toolkit-lib/__tests__/wrapper.test.ts diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 97ccf383f..3e30d6673 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -244,11 +244,47 @@ that close the escalation paths. Replace `ACCOUNT_ID` with your account number b Together these ensure that even though `iam:CreateRole` targets `Resource: "*"`, every created role is capped by the boundary, and neither the boundary nor its attachment can be tampered with. -> **Note:** These deny statements require a corresponding update to the AgentCore CDK constructs. The constructs do not -> currently attach a `permissionsBoundary` to the IAM roles they create (runtime, memory, gateway, etc.), so -> CloudFormation will fail to create those roles when `ForceExecutionRoleBoundary` is active. Until the CDK constructs -> are updated to accept and apply a permission boundary ARN, treat this section as a recommended future configuration. -> You can still create the boundary policy (Step 1) and scope the user policy (Step 3) today. +> **Required:** `ForceExecutionRoleBoundary` denies `iam:CreateRole` unless the new role carries the boundary, so you +> must also declare the boundary in your project — otherwise `agentcore deploy` fails while CloudFormation creates the +> agent runtime execution role. See [Step 2b](#step-2b-declare-the-boundary-in-your-project). + +### Step 2b: Declare the boundary so the CLI applies it + +A boundary is a property of the account you deploy into, so the usual place for it is your machine's global config — set +it once and every project on that machine picks it up: + +```bash +agentcore config permissionsBoundary AgentCoreExecutionRoleBoundary +``` + +If instead your whole team deploys into the same boundary-enforcing account and you want the constraint reviewed and +reproducible in CI, commit it to `agentcore/agentcore.json`: + +```json +{ + "name": "MyProject", + "version": 1, + "iam": { + "permissionsBoundary": "AgentCoreExecutionRoleBoundary" + } +} +``` + +Either way the CLI applies it to every IAM role the project creates — the agent runtime execution role plus memory, +gateway, harness, payment and A/B test roles. A bare policy name is resolved against each deployment target's own +partition and account, so a single value works across accounts and partitions. A full policy ARN +(`arn:aws:iam::111122223333:policy/AgentCoreExecutionRoleBoundary`) is used verbatim. + +Sources are consulted most-specific first, so a project value overrides the machine default, and +`AGENTCORE_PERMISSIONS_BOUNDARY` overrides both — useful in CI, or for an account whose boundary differs from the one +committed to the project: + +```bash +AGENTCORE_PERMISSIONS_BOUNDARY=arn:aws:iam::111122223333:policy/AgentCoreExecutionRoleBoundary agentcore deploy +``` + +Confirm it landed before deploying, with `agentcore deploy --diff` or by inspecting the synthesized template — every +`AWS::IAM::Role` should carry a `PermissionsBoundary` property. ### Step 3: Scope the user policy to your account @@ -303,6 +339,12 @@ policy. for the token vault. The developer policy needs `kms:CreateKey` and `kms:TagResource`. If your organization restricts KMS key creation, have an admin pre-create the key and configure it via the token vault settings. +**Deploy fails with `not authorized to perform: iam:CreateRole ... with an explicit deny in a permissions boundary`.** +Your account requires every new role to carry a permissions boundary. The CLI detects this specific failure and prints +the boundary the account expects along with the command to set it, so following that hint and redeploying is usually +enough. See [Step 2b](#step-2b-declare-the-boundary-so-the-cli-applies-it). Raw CloudFormation reports this as +`UnauthorizedTaggingOperation` because the denied `CreateRole` call also carries tags; the boundary is the actual cause. + --- # Permissions Reference diff --git a/docs/configuration.md b/docs/configuration.md index f6c4e0391..9e83eb051 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,25 +48,72 @@ Main project configuration using a **flat resource model**. Agents, memories, an ### Project Fields -| Field | Required | Description | -| ------------------- | -------- | ----------------------------------------------------------- | -| `name` | Yes | Project name (1-23 chars, alphanumeric, starts with letter) | -| `version` | Yes | Schema version (integer, currently `1`) | -| `tags` | No | Project-level tags applied to all resources | -| `runtimes` | Yes | Array of agent specifications | -| `memories` | Yes | Array of memory resources | -| `credentials` | Yes | Array of credential providers (API key or OAuth) | -| `evaluators` | Yes | Array of custom evaluator definitions | -| `onlineEvalConfigs` | Yes | Array of online eval configurations | -| `payments` | No | Array of payment manager configurations | -| `policyEngines` | No | Array of policy engine configurations | -| `agentCoreGateways` | No | Array of gateway definitions | -| `mcpRuntimeTools` | No | Array of MCP runtime tool definitions | -| `unassignedTargets` | No | Targets not yet assigned to a gateway | +| Field | Required | Description | +| ------------------- | -------- | ------------------------------------------------------------ | +| `name` | Yes | Project name (1-23 chars, alphanumeric, starts with letter) | +| `version` | Yes | Schema version (integer, currently `1`) | +| `tags` | No | Project-level tags applied to all resources | +| `iam` | No | Project-wide IAM settings. See [IAM Settings](#iam-settings) | +| `runtimes` | Yes | Array of agent specifications | +| `memories` | Yes | Array of memory resources | +| `credentials` | Yes | Array of credential providers (API key or OAuth) | +| `evaluators` | Yes | Array of custom evaluator definitions | +| `onlineEvalConfigs` | Yes | Array of online eval configurations | +| `payments` | No | Array of payment manager configurations | +| `policyEngines` | No | Array of policy engine configurations | +| `agentCoreGateways` | No | Array of gateway definitions | +| `mcpRuntimeTools` | No | Array of MCP runtime tool definitions | +| `unassignedTargets` | No | Targets not yet assigned to a gateway | > Gateway configuration is in the `agentCoreGateways` field. See [Gateways and MCP Tools](#gateways-and-mcp-tools) > below. +### IAM Settings + +```json +{ + "iam": { + "permissionsBoundary": "AgentCoreExecutionRoleBoundary" + } +} +``` + +| Field | Required | Description | +| --------------------- | -------- | ---------------------------------------------------------------------------------------- | +| `permissionsBoundary` | No | IAM policy name or policy ARN attached as the permissions boundary of every project role | + +`permissionsBoundary` is applied to every IAM role the project creates — agent runtime execution roles, memory, gateway, +harness, payment and A/B test roles included. Set it when your account denies `iam:CreateRole` unless the new role +carries a boundary, which otherwise fails `agentcore deploy` with an `explicit deny in a permissions boundary` error. + +A bare policy name is resolved against each deployment target's own partition and account, so one value works across +targets and partitions. A full ARN is used verbatim. + +Because a boundary is a property of the account rather than of the project, it can also be set per machine, which is +usually the better default — it applies to every project and is not committed: + +```bash +agentcore config permissionsBoundary AgentCoreExecutionRoleBoundary +``` + +Sources are consulted most-specific first: `AGENTCORE_PERMISSIONS_BOUNDARY`, then `iam.permissionsBoundary` here, then +the machine's global config. Put it in `agentcore.json` when the whole team deploys into the same boundary-enforcing +account and you want the constraint reviewed in git; use the global config otherwise. + +To clear the machine default, set it to an empty string — a blank value counts as unset, and `agentcore config` can only +write keys, not remove them: + +```bash +agentcore config permissionsBoundary '' +``` + +In `agentcore.json`, omit the `iam.permissionsBoundary` key instead; a blank value there is a validation error. + +> The `iam` block holds constraints imposed on the project from outside — things the account requires of any role. It is +> not a place to author roles; per-resource `executionRoleArn` remains the way to supply a role you manage yourself. + +See [Permissions](./PERMISSIONS.md#hardening-with-permission-boundaries) for the boundary policy itself. + --- ## Tags diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index 860e07f44..fa0b2f218 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -35,6 +35,16 @@ "pattern": "^[\\p{L}\\p{N}\\s_.:/=+\\-@]*$" } }, + "iam": { + "type": "object", + "properties": { + "permissionsBoundary": { + "type": "string", + "pattern": "^(?:arn:[^:]+:iam::[^:]*:policy\\/.+|[\\w+=,.@-]{1,128})$" + } + }, + "additionalProperties": false + }, "runtimes": { "default": [], "type": "array", diff --git a/src/cli/aws/__tests__/permissions-boundary.test.ts b/src/cli/aws/__tests__/permissions-boundary.test.ts new file mode 100644 index 000000000..2066f5fb9 --- /dev/null +++ b/src/cli/aws/__tests__/permissions-boundary.test.ts @@ -0,0 +1,144 @@ +import { CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY, PERMISSIONS_BOUNDARY_ENV_VAR } from '../../constants'; +import { + isPermissionsBoundaryArn, + permissionsBoundaryCdkContext, + resolvePermissionsBoundary, + toPermissionsBoundaryArn, +} from '../permissions-boundary'; +import { describe, expect, it } from 'vitest'; + +const BOUNDARY_NAME = 'AgentCoreExecutionRoleBoundary'; +const BOUNDARY_ARN = `arn:aws:iam::111122223333:policy/${BOUNDARY_NAME}`; + +describe('resolvePermissionsBoundary', () => { + it('returns undefined when nothing is configured', () => { + expect(resolvePermissionsBoundary({ env: {} })).toBeUndefined(); + }); + + it('prefers the explicit override over the environment and config', () => { + const resolved = resolvePermissionsBoundary({ + override: 'FromFlag', + configured: 'FromConfig', + env: { [PERMISSIONS_BOUNDARY_ENV_VAR]: 'FromEnv' }, + }); + + expect(resolved).toBe('FromFlag'); + }); + + it('prefers the environment over the project config', () => { + const resolved = resolvePermissionsBoundary({ + configured: 'FromConfig', + env: { [PERMISSIONS_BOUNDARY_ENV_VAR]: 'FromEnv' }, + }); + + expect(resolved).toBe('FromEnv'); + }); + + it('falls back to the project config', () => { + expect(resolvePermissionsBoundary({ configured: BOUNDARY_NAME, env: {} })).toBe(BOUNDARY_NAME); + }); + + it('prefers the project config over the machine global config', () => { + expect(resolvePermissionsBoundary({ configured: 'FromProject', global: 'FromGlobal', env: {} })).toBe( + 'FromProject' + ); + }); + + it('falls back to the machine global config', () => { + expect(resolvePermissionsBoundary({ global: BOUNDARY_NAME, env: {} })).toBe(BOUNDARY_NAME); + }); + + it('trims values and skips blank ones', () => { + const resolved = resolvePermissionsBoundary({ + configured: ` ${BOUNDARY_NAME} `, + env: { [PERMISSIONS_BOUNDARY_ENV_VAR]: ' ' }, + }); + + expect(resolved).toBe(BOUNDARY_NAME); + }); + + // `agentcore config` can only write keys, so `agentcore config permissionsBoundary ''` is the + // only way to clear the machine default. A blank must therefore read as unset, not as a + // fall-through to the next source. + it('treats a blank value as unset at every source', () => { + expect(resolvePermissionsBoundary({ global: '', env: {} })).toBeUndefined(); + expect(resolvePermissionsBoundary({ global: ' ', env: {} })).toBeUndefined(); + expect(resolvePermissionsBoundary({ configured: '', env: {} })).toBeUndefined(); + expect(resolvePermissionsBoundary({ override: '', env: {} })).toBeUndefined(); + expect(resolvePermissionsBoundary({ env: { [PERMISSIONS_BOUNDARY_ENV_VAR]: '' } })).toBeUndefined(); + }); + + it('does not let a blank higher-precedence source mask a lower one', () => { + expect(resolvePermissionsBoundary({ override: '', configured: BOUNDARY_NAME, env: {} })).toBe(BOUNDARY_NAME); + expect(resolvePermissionsBoundary({ configured: ' ', global: BOUNDARY_NAME, env: {} })).toBe(BOUNDARY_NAME); + }); + + it('resolves the full precedence chain in order', () => { + const all = { + override: 'FromOverride', + configured: 'FromProject', + global: 'FromGlobal', + env: { [PERMISSIONS_BOUNDARY_ENV_VAR]: 'FromEnv' }, + }; + + expect(resolvePermissionsBoundary(all)).toBe('FromOverride'); + expect(resolvePermissionsBoundary({ ...all, override: undefined })).toBe('FromEnv'); + expect(resolvePermissionsBoundary({ ...all, override: undefined, env: {} })).toBe('FromProject'); + expect(resolvePermissionsBoundary({ ...all, override: undefined, env: {}, configured: undefined })).toBe( + 'FromGlobal' + ); + }); +}); + +describe('isPermissionsBoundaryArn', () => { + it('recognises ARNs across partitions', () => { + expect(isPermissionsBoundaryArn(BOUNDARY_ARN)).toBe(true); + expect(isPermissionsBoundaryArn('arn:aws-cn:iam::111122223333:policy/Boundary')).toBe(true); + expect(isPermissionsBoundaryArn('arn:aws-us-gov:iam::111122223333:policy/Boundary')).toBe(true); + }); + + it('treats bare policy names as names', () => { + expect(isPermissionsBoundaryArn(BOUNDARY_NAME)).toBe(false); + expect(isPermissionsBoundaryArn('arnold-boundary')).toBe(false); + }); +}); + +describe('permissionsBoundaryCdkContext', () => { + it('maps a policy name to the CDK name form', () => { + expect(permissionsBoundaryCdkContext(BOUNDARY_NAME)).toEqual({ + [CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { name: BOUNDARY_NAME }, + }); + }); + + it('maps a policy ARN to the CDK arn form', () => { + expect(permissionsBoundaryCdkContext(BOUNDARY_ARN)).toEqual({ + [CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { arn: BOUNDARY_ARN }, + }); + }); +}); + +describe('toPermissionsBoundaryArn', () => { + it('expands a policy name using the target partition and account', () => { + expect(toPermissionsBoundaryArn(BOUNDARY_NAME, { region: 'us-east-1', accountId: '111122223333' })).toBe( + BOUNDARY_ARN + ); + }); + + it('uses the China partition for cn regions', () => { + expect(toPermissionsBoundaryArn(BOUNDARY_NAME, { region: 'cn-north-1', accountId: '111122223333' })).toBe( + `arn:aws-cn:iam::111122223333:policy/${BOUNDARY_NAME}` + ); + }); + + it('uses the GovCloud partition for us-gov regions', () => { + expect(toPermissionsBoundaryArn(BOUNDARY_NAME, { region: 'us-gov-west-1', accountId: '111122223333' })).toBe( + `arn:aws-us-gov:iam::111122223333:policy/${BOUNDARY_NAME}` + ); + }); + + it('passes an ARN through unchanged', () => { + expect(toPermissionsBoundaryArn(BOUNDARY_ARN, { region: 'us-east-1', accountId: '999988887777' })).toBe( + BOUNDARY_ARN + ); + }); +}); diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts index 09851e678..bbd594419 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -1,6 +1,15 @@ export { detectAwsContext, type AwsContext } from './aws-context'; export { detectAccount, getCredentialProvider } from './account'; export { getPartition, arnPrefix, dnsSuffix, serviceEndpoint, consoleDomain } from './partition'; +export { + resolvePermissionsBoundary, + isPermissionsBoundaryArn, + permissionsBoundaryCdkContext, + toPermissionsBoundaryArn, + type PermissionsBoundaryContextValue, + type ResolvePermissionsBoundaryOptions, + type PermissionsBoundaryArnContext, +} from './permissions-boundary'; export { detectRegion, type RegionDetectionResult } from './region'; export { applyTargetRegionToEnv, withTargetRegion } from './target-region'; export { diff --git a/src/cli/aws/permissions-boundary.ts b/src/cli/aws/permissions-boundary.ts new file mode 100644 index 000000000..c58152814 --- /dev/null +++ b/src/cli/aws/permissions-boundary.ts @@ -0,0 +1,102 @@ +/** + * IAM permissions boundary resolution. + * + * Organizations commonly attach a boundary to the CDK CloudFormation execution role that + * denies `iam:CreateRole` unless the new role carries a boundary of its own: + * + * ``` + * "Condition": { "StringNotEquals": { "iam:PermissionsBoundary": "arn:aws:iam:::policy/" } } + * ``` + * + * Without a way to declare that boundary, `agentcore deploy` fails while CloudFormation + * creates the agent runtime execution role. See docs/PERMISSIONS.md. + * + * @module permissions-boundary + */ +import { ARN_PREFIX, CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY, PERMISSIONS_BOUNDARY_ENV_VAR } from '../constants'; +import { arnPrefix } from './partition'; + +/** + * Value shape aws-cdk-lib expects under {@link CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY}. + * Exactly one of `name` / `arn` is set; `name` is expanded by the CDK against the stack's + * own partition and account, which keeps a single config value valid across targets. + */ +export interface PermissionsBoundaryContextValue { + name?: string; + arn?: string; +} + +export interface ResolvePermissionsBoundaryOptions { + /** Explicit value that wins over everything else. */ + override?: string; + /** `iam.permissionsBoundary` from the project's agentcore.json. */ + configured?: string; + /** `permissionsBoundary` from ~/.agentcore/config.json. */ + global?: string; + /** Process environment. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; +} + +/** + * Resolve the permissions boundary to apply, most specific source first: explicit override, + * `AGENTCORE_PERMISSIONS_BOUNDARY`, the project's `iam.permissionsBoundary`, then the machine's + * global config. Returns undefined when none is configured. + * + * The project beats the machine because committing the value is a deliberate statement that + * this project always deploys into a boundary-enforcing account; the global config is the + * fallback default for every project on a developer's machine. + * + * A blank value counts as unset rather than falling through to the next source. That is the + * expected reading of an empty environment variable, and it gives `agentcore config` a way to + * clear the machine default — the command can only write values, not remove keys. Note the + * project schema rejects a blank `iam.permissionsBoundary` instead: you are editing that file + * by hand, so the key should simply be omitted. + */ +export function resolvePermissionsBoundary(options: ResolvePermissionsBoundaryOptions = {}): string | undefined { + const env = options.env ?? process.env; + const candidates = [options.override, env[PERMISSIONS_BOUNDARY_ENV_VAR], options.configured, options.global]; + for (const candidate of candidates) { + const trimmed = candidate?.trim(); + if (trimmed) { + return trimmed; + } + } + return undefined; +} + +/** + * Whether the value is already a full IAM policy ARN rather than a bare policy name. + * IAM policy names cannot contain `:` (`[\w+=,.@-]+`), so the prefix check is unambiguous. + */ +export function isPermissionsBoundaryArn(boundary: string): boolean { + return boundary.trim().startsWith(ARN_PREFIX); +} + +/** + * Build the CDK context entry that makes aws-cdk-lib attach the boundary to every + * `AWS::IAM::Role` and `AWS::IAM::User` in a stack, including roles created inside the + * `@aws/agentcore-cdk` L3 constructs. + */ +export function permissionsBoundaryCdkContext(boundary: string): Record { + const trimmed = boundary.trim(); + return { + [CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY]: isPermissionsBoundaryArn(trimmed) ? { arn: trimmed } : { name: trimmed }, + }; +} + +export interface PermissionsBoundaryArnContext { + region: string; + accountId: string; +} + +/** + * Expand a boundary value into a full policy ARN for direct IAM API calls, which — unlike + * CloudFormation — cannot resolve a bare policy name. Values that are already ARNs pass through. + */ +export function toPermissionsBoundaryArn(boundary: string, context: PermissionsBoundaryArnContext): string { + const trimmed = boundary.trim(); + if (isPermissionsBoundaryArn(trimmed)) { + return trimmed; + } + return `${arnPrefix(context.region)}:iam::${context.accountId}:policy/${trimmed}`; +} diff --git a/src/cli/cdk/__tests__/permissions-boundary.test.ts b/src/cli/cdk/__tests__/permissions-boundary.test.ts new file mode 100644 index 000000000..3032295c3 --- /dev/null +++ b/src/cli/cdk/__tests__/permissions-boundary.test.ts @@ -0,0 +1,296 @@ +import { CONFIG_DIR, CONFIG_FILES } from '../../../lib/constants'; +import { PermissionsBoundaryRequiredError } from '../../../lib/errors/types'; +import { CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY, CDK_PROJECT_DIR, PERMISSIONS_BOUNDARY_ENV_VAR } from '../../constants'; +import { + isPermissionsBoundaryDenial, + readPermissionsBoundary, + readPermissionsBoundaryContext, + rewriteIfPermissionsBoundaryRequired, +} from '../permissions-boundary'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The global config path is resolved at module load, so the read is mocked rather than +// redirected. Keeps the suite independent of whatever ~/.agentcore/config.json holds. +const { readGlobalConfigMock } = vi.hoisted(() => ({ + readGlobalConfigMock: vi.fn(), +})); + +vi.mock('../../../lib/schemas/io/global-config', () => ({ + readGlobalConfig: readGlobalConfigMock, +})); + +const BOUNDARY_NAME = 'AgentCoreExecutionRoleBoundary'; + +const tmpRoot = mkdtempSync(join(tmpdir(), 'agentcore-boundary-test-')); + +afterAll(() => { + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +// File-level, not per-describe: every resolver in here consults the environment and the machine +// config, so a developer with AGENTCORE_PERMISSIONS_BOUNDARY exported or a boundary in +// ~/.agentcore/config.json would otherwise fail these tests. +const savedEnv = process.env[PERMISSIONS_BOUNDARY_ENV_VAR]; + +beforeEach(() => { + delete process.env[PERMISSIONS_BOUNDARY_ENV_VAR]; + readGlobalConfigMock.mockResolvedValue({ success: true, config: {} }); +}); + +afterEach(() => { + if (savedEnv === undefined) { + delete process.env[PERMISSIONS_BOUNDARY_ENV_VAR]; + } else { + process.env[PERMISSIONS_BOUNDARY_ENV_VAR] = savedEnv; + } +}); + +/** + * Lay out a minimal `/agentcore/{agentcore.json,cdk}` project and return the CDK + * project directory, which is what the CDK toolkit wrapper hands to the resolver. + */ +function writeProject(spec: Record): string { + const projectRoot = mkdtempSync(join(tmpRoot, 'project-')); + const configDir = join(projectRoot, CONFIG_DIR); + const cdkDir = join(configDir, CDK_PROJECT_DIR); + mkdirSync(cdkDir, { recursive: true }); + writeFileSync(join(configDir, CONFIG_FILES.AGENT_ENV), JSON.stringify(spec)); + return cdkDir; +} + +function baseSpec(iam?: Record): Record { + return { + name: 'testproject', + version: 1, + managedBy: 'CDK', + runtimes: [], + ...(iam && { iam }), + }; +} + +describe('readPermissionsBoundary', () => { + it('reads iam.permissionsBoundary from agentcore.json', async () => { + const cdkDir = writeProject(baseSpec({ permissionsBoundary: BOUNDARY_NAME })); + + await expect(readPermissionsBoundary(cdkDir)).resolves.toBe(BOUNDARY_NAME); + }); + + it('returns undefined when the project declares no boundary', async () => { + const cdkDir = writeProject(baseSpec()); + + await expect(readPermissionsBoundary(cdkDir)).resolves.toBeUndefined(); + }); + + it('lets the environment override the project config', async () => { + const cdkDir = writeProject(baseSpec({ permissionsBoundary: BOUNDARY_NAME })); + process.env[PERMISSIONS_BOUNDARY_ENV_VAR] = 'FromEnv'; + + await expect(readPermissionsBoundary(cdkDir)).resolves.toBe('FromEnv'); + }); + + it('lets an explicit override win over the environment', async () => { + const cdkDir = writeProject(baseSpec({ permissionsBoundary: BOUNDARY_NAME })); + process.env[PERMISSIONS_BOUNDARY_ENV_VAR] = 'FromEnv'; + + await expect(readPermissionsBoundary(cdkDir, 'FromOverride')).resolves.toBe('FromOverride'); + }); + + it('still honours the environment when agentcore.json is missing', async () => { + const cdkDir = join(tmpRoot, 'no-such-project', CONFIG_DIR, CDK_PROJECT_DIR); + process.env[PERMISSIONS_BOUNDARY_ENV_VAR] = 'FromEnv'; + + await expect(readPermissionsBoundary(cdkDir)).resolves.toBe('FromEnv'); + }); + + it('falls back to the machine global config when the project declares none', async () => { + const cdkDir = writeProject(baseSpec()); + readGlobalConfigMock.mockResolvedValue({ success: true, config: { permissionsBoundary: BOUNDARY_NAME } }); + + await expect(readPermissionsBoundary(cdkDir)).resolves.toBe(BOUNDARY_NAME); + }); + + it('lets the project config override the machine global config', async () => { + const cdkDir = writeProject(baseSpec({ permissionsBoundary: 'FromProject' })); + readGlobalConfigMock.mockResolvedValue({ success: true, config: { permissionsBoundary: 'FromGlobal' } }); + + await expect(readPermissionsBoundary(cdkDir)).resolves.toBe('FromProject'); + }); + + it('ignores an unreadable global config', async () => { + const cdkDir = writeProject(baseSpec()); + readGlobalConfigMock.mockResolvedValue({ success: false, error: new Error('bad json') }); + + await expect(readPermissionsBoundary(cdkDir)).resolves.toBeUndefined(); + }); + + // The state `agentcore config permissionsBoundary ''` leaves behind. + it('treats a blank global config value as no boundary', async () => { + const cdkDir = writeProject(baseSpec()); + readGlobalConfigMock.mockResolvedValue({ success: true, config: { permissionsBoundary: '' } }); + + await expect(readPermissionsBoundary(cdkDir)).resolves.toBeUndefined(); + }); +}); + +describe('readPermissionsBoundaryContext', () => { + it('produces the CDK context entry for a configured boundary', async () => { + const cdkDir = writeProject(baseSpec({ permissionsBoundary: BOUNDARY_NAME })); + + await expect(readPermissionsBoundaryContext(cdkDir)).resolves.toEqual({ + [CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { name: BOUNDARY_NAME }, + }); + }); + + it('produces no context when no boundary is configured', async () => { + const cdkDir = writeProject(baseSpec()); + + await expect(readPermissionsBoundaryContext(cdkDir)).resolves.toBeUndefined(); + }); +}); + +describe('rewriteIfPermissionsBoundaryRequired', () => { + const REQUIRED_ARN = 'arn:aws:iam::111122223333:policy/OrgBoundary'; + + /** Real CloudFormation status reason for this failure, abridged. */ + const cfnDenial = + 'Resource handler returned message: "Encountered a permissions error performing a tagging ' + + 'operation, please add required tag permissions. Resource handler returned message: "User: ' + + 'arn:aws:sts::111122223333:assumed-role/cdk-hnb659fds-cfn-exec-role-111122223333-us-east-1/AWSCloudFormation ' + + 'is not authorized to perform: iam:CreateRole on resource: ' + + 'arn:aws:iam::111122223333:role/AgentCore-proj-default-ApplicationAgentMyAgentRu-abc123 ' + + `with an explicit deny in a permissions boundary: ${REQUIRED_ARN} ` + + '(Service: Iam, Status Code: 403, Request ID: 1234)"" (HandlerErrorCode: UnauthorizedTaggingOperation)'; + + /** Real CDK_TOOLKIT_I5502 progress message for the same failure, abridged. */ + const progressDenial = + 'AgentCore-proj-default | 1/5 | 11:03:16 AM | CREATE_FAILED | AWS::IAM::Role | ' + + 'Application/AgentMyAgent/Runtime/ExecutionRole (ApplicationAgentMyAgentRuntimeExecutionRole5E90F22B) ' + + `${cfnDenial}`; + + it('rewrites a flat denial and extracts the required boundary', () => { + const rewritten = rewriteIfPermissionsBoundaryRequired(new Error(cfnDenial)); + + expect(rewritten).toBeInstanceOf(PermissionsBoundaryRequiredError); + const err = rewritten as PermissionsBoundaryRequiredError; + expect(err.requiredBoundaryArn).toBe(REQUIRED_ARN); + expect(err.errorSource).toBe('user'); + expect(err.cause).toBeInstanceOf(Error); + }); + + it('finds the denial through a nested cause chain', () => { + const wrapped = new Error('CDK deploy failed: deployment failed', { + cause: new Error('❌ AgentCore-proj-default failed', { cause: new Error(cfnDenial) }), + }); + + const rewritten = rewriteIfPermissionsBoundaryRequired(wrapped); + + expect(rewritten).toBeInstanceOf(PermissionsBoundaryRequiredError); + expect((rewritten as PermissionsBoundaryRequiredError).requiredBoundaryArn).toBe(REQUIRED_ARN); + }); + + it('names the mismatch when a boundary was already applied', () => { + const message = ( + rewriteIfPermissionsBoundaryRequired(new Error(cfnDenial), { appliedBoundary: 'WrongBoundary' }) as Error + ).message; + + expect(message).toContain('Applied: WrongBoundary'); + expect(message).toContain(`Required: ${REQUIRED_ARN}`); + // Must not claim the role had no boundary — it had one, just not the required one. + expect(message).not.toContain('had none'); + // The setup instructions belong to the other branch only. + expect(message).not.toContain('agentcore config permissionsBoundary'); + }); + + // The shape this actually takes in practice: CloudFormation rolls the stack back and the + // toolkit throws NoStack with no cause, so the reason is only on the captured progress message. + it('rewrites a NoStack failure using a denial captured from progress messages', () => { + const noStack = new Error( + 'CDK deploy failed: ❌ AgentCore-proj-default failed: NoStack: CloudFormationStack object does not hold a stack' + ); + + const rewritten = rewriteIfPermissionsBoundaryRequired(noStack, { observedDenial: progressDenial }); + + expect(rewritten).toBeInstanceOf(PermissionsBoundaryRequiredError); + expect((rewritten as PermissionsBoundaryRequiredError).requiredBoundaryArn).toBe(REQUIRED_ARN); + expect((rewritten as Error).cause).toBe(noStack); + }); + + it('ignores a captured message that is not a boundary denial', () => { + const err = new Error('NoStack: CloudFormationStack object does not hold a stack'); + + expect( + rewriteIfPermissionsBoundaryRequired(err, { + observedDenial: 'CREATE_FAILED | AWS::IAM::Role | some other reason', + }) + ).toBe(err); + }); + + it('tells the user how to set the boundary when none was applied', () => { + const message = (rewriteIfPermissionsBoundaryRequired(new Error(cfnDenial)) as Error).message; + + expect(message).toContain(`agentcore config permissionsBoundary ${REQUIRED_ARN}`); + expect(message).toContain('"iam": { "permissionsBoundary"'); + }); + + it('still rewrites when the boundary ARN cannot be extracted', () => { + const err = new Error( + 'is not authorized to perform: iam:CreateRole on resource: arn:aws:iam::111122223333:role/Foo ' + + 'with an explicit deny in a permissions boundary' + ); + + const rewritten = rewriteIfPermissionsBoundaryRequired(err); + + expect(rewritten).toBeInstanceOf(PermissionsBoundaryRequiredError); + expect((rewritten as PermissionsBoundaryRequiredError).requiredBoundaryArn).toBeUndefined(); + }); + + it('passes unrelated errors through untouched', () => { + const err = new Error('CDK deploy failed: stack is in ROLLBACK_COMPLETE state'); + + expect(rewriteIfPermissionsBoundaryRequired(err)).toBe(err); + }); + + it('leaves a boundary denial on a different action alone', () => { + const err = new Error( + 'is not authorized to perform: iam:PutRolePermissionsBoundary on resource: ' + + 'arn:aws:iam::111122223333:role/Foo with an explicit deny in a permissions boundary: ' + + REQUIRED_ARN + ); + + expect(rewriteIfPermissionsBoundaryRequired(err)).toBe(err); + }); + + it('leaves a plain CreateRole denial alone when no boundary is involved', () => { + const err = new Error( + 'is not authorized to perform: iam:CreateRole on resource: arn:aws:iam::111122223333:role/Foo ' + + 'because no identity-based policy allows the iam:CreateRole action' + ); + + expect(rewriteIfPermissionsBoundaryRequired(err)).toBe(err); + }); + + it('survives a self-referential cause chain', () => { + const err: Error & { cause?: unknown } = new Error('loop'); + err.cause = err; + + expect(() => rewriteIfPermissionsBoundaryRequired(err)).not.toThrow(); + }); + + describe('isPermissionsBoundaryDenial', () => { + it('recognizes both the handler error and the progress message', () => { + expect(isPermissionsBoundaryDenial(cfnDenial)).toBe(true); + expect(isPermissionsBoundaryDenial(progressDenial)).toBe(true); + }); + + it('requires both the action and the boundary phrase', () => { + expect(isPermissionsBoundaryDenial('is not authorized to perform: iam:CreateRole')).toBe(false); + expect(isPermissionsBoundaryDenial('explicit deny in a permissions boundary: arn:aws:iam::1:policy/B')).toBe( + false + ); + expect(isPermissionsBoundaryDenial('CREATE_IN_PROGRESS | AWS::IAM::Role | fine')).toBe(false); + }); + }); +}); diff --git a/src/cli/cdk/permissions-boundary.ts b/src/cli/cdk/permissions-boundary.ts new file mode 100644 index 000000000..6d453bf8d --- /dev/null +++ b/src/cli/cdk/permissions-boundary.ts @@ -0,0 +1,131 @@ +/** + * Reads the project's permissions boundary setting for the vended CDK project. + * + * Resolution lives here rather than at each call site so that every CDK operation + * (synth, deploy, diff, destroy) sees the same boundary. A boundary that is applied on + * deploy but not on diff would show up as permanent drift, and a boundary that is silently + * skipped by one code path produces roles the account's own boundary policy is meant to cap. + * + * @module cdk/permissions-boundary + */ +import { ConfigIO } from '../../lib'; +import { PermissionsBoundaryRequiredError } from '../../lib/errors/types'; +import { readGlobalConfig } from '../../lib/schemas/io/global-config'; +import { + type PermissionsBoundaryContextValue, + permissionsBoundaryCdkContext, + resolvePermissionsBoundary, +} from '../aws/permissions-boundary'; +import * as path from 'node:path'; + +/** + * Resolve the permissions boundary for the project that owns `cdkProjectDir` + * (`/agentcore/cdk`), across all sources in the precedence documented on + * {@link resolvePermissionsBoundary}. + * + * An unreadable or invalid agentcore.json is not reported here: the deploy pipeline validates + * the spec separately and surfaces a far better message than this lookup could. + */ +export async function readPermissionsBoundary(cdkProjectDir: string, override?: string): Promise { + let configured: string | undefined; + try { + const configIO = new ConfigIO({ baseDir: path.dirname(cdkProjectDir) }); + const spec = await configIO.readProjectSpec(); + configured = spec.iam?.permissionsBoundary; + } catch { + // Fall through to the remaining sources. + } + const globalRead = await readGlobalConfig(); + return resolvePermissionsBoundary({ + override, + configured, + global: globalRead.success ? globalRead.config.permissionsBoundary : undefined, + }); +} + +/** + * CDK context to hand the vended app so aws-cdk-lib attaches the boundary to every IAM role + * in the stack, or undefined when no boundary is configured. + */ +export async function readPermissionsBoundaryContext( + cdkProjectDir: string, + override?: string +): Promise | undefined> { + const boundary = await readPermissionsBoundary(cdkProjectDir, override); + return boundary ? permissionsBoundaryCdkContext(boundary) : undefined; +} + +/** Depth cap so a self-referential `cause` chain cannot spin forever. */ +const MAX_CAUSE_DEPTH = 10; + +/** + * Flatten an error and its `cause` chain into one searchable string. toolkit-lib wraps the + * CloudFormation failure several layers deep, so the IAM denial is never on the top message. + */ +function flattenErrorMessages(err: unknown, depth = 0): string { + if (depth >= MAX_CAUSE_DEPTH) { + return ''; + } + if (!(err instanceof Error)) { + return typeof err === 'string' ? err : ''; + } + const nested = err.cause ? flattenErrorMessages(err.cause, depth + 1) : ''; + return nested ? `${err.message}\n${nested}` : err.message; +} + +/** + * Matches the IAM denial CloudFormation nests inside its handler error, e.g. + * `... is not authorized to perform: iam:CreateRole on resource: arn:...:role/Foo with an + * explicit deny in a permissions boundary: arn:aws:iam::111122223333:policy/Boundary`. + * + * Deliberately narrow: only `iam:CreateRole` denied by a boundary has the one-line fix this + * rewrite advertises. A boundary denying some other action needs a different remedy, so those + * errors are left alone. + */ +const CREATE_ROLE_ACTION = 'iam:CreateRole'; +const BOUNDARY_DENY_PATTERN = /explicit deny in a permissions boundary:\s*(arn:[^\s"')]+)/; +const BOUNDARY_DENY_PHRASE = 'explicit deny in a permissions boundary'; + +/** + * Whether `text` reports a boundary-denied `iam:CreateRole`. + * + * Used both on thrown errors and on the toolkit's progress messages, because which of the two + * carries the reason depends on how CloudFormation ends the deployment. + */ +export function isPermissionsBoundaryDenial(text: string): boolean { + return text.includes(CREATE_ROLE_ACTION) && text.includes(BOUNDARY_DENY_PHRASE); +} + +export interface PermissionsBoundaryFailureContext { + /** Boundary the deploy did apply, so a mismatch can be called out. */ + appliedBoundary?: string; + /** + * Denial text seen on the toolkit's progress messages during the deploy. + * + * A rolled-back create surfaces the resource failure only as a `CDK_TOOLKIT_I5502` progress + * message and then throws an unrelated `NoStack` error with no cause, so the thrown error on + * its own is not enough to recognize this failure. + */ + observedDenial?: string; +} + +/** + * Rewrite a boundary-required deploy failure into {@link PermissionsBoundaryRequiredError}. + * Any other error is returned unchanged. + */ +export function rewriteIfPermissionsBoundaryRequired( + err: unknown, + context: PermissionsBoundaryFailureContext = {} +): unknown { + const fromError = flattenErrorMessages(err); + const evidence = isPermissionsBoundaryDenial(fromError) + ? fromError + : context.observedDenial && isPermissionsBoundaryDenial(context.observedDenial) + ? context.observedDenial + : undefined; + if (!evidence) { + return err; + } + const requiredBoundaryArn = BOUNDARY_DENY_PATTERN.exec(evidence)?.[1]; + return new PermissionsBoundaryRequiredError(requiredBoundaryArn, context.appliedBoundary, { cause: err }); +} diff --git a/src/cli/cdk/toolkit-lib/__tests__/wrapper.test.ts b/src/cli/cdk/toolkit-lib/__tests__/wrapper.test.ts new file mode 100644 index 000000000..9d6638088 --- /dev/null +++ b/src/cli/cdk/toolkit-lib/__tests__/wrapper.test.ts @@ -0,0 +1,203 @@ +import { PermissionsBoundaryRequiredError } from '../../../../lib/errors/types'; +import { CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY, PERMISSIONS_BOUNDARY_ENV_VAR } from '../../../constants'; +import { CdkToolkitWrapper } from '../wrapper'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { fromCdkApp, deploy, readGlobalConfigMock, contextStoreCalls, toolkitProps } = vi.hoisted(() => ({ + fromCdkApp: vi.fn().mockResolvedValue({}), + deploy: vi.fn().mockResolvedValue({}), + readGlobalConfigMock: vi.fn(), + contextStoreCalls: [] as { appDirectory: string; commandlineContext?: Record }[], + toolkitProps: [] as { ioHost?: { notify: (msg: unknown) => Promise } }[], +})); + +vi.mock('@aws-cdk/toolkit-lib', () => ({ + Toolkit: class { + constructor(props: { ioHost?: { notify: (msg: unknown) => Promise } }) { + toolkitProps.push(props); + } + fromCdkApp = fromCdkApp; + deploy = deploy; + }, + BaseCredentials: { awsCliCompatible: vi.fn().mockReturnValue({}) }, + BootstrapEnvironments: { fromList: vi.fn() }, + BootstrapStackParameters: { exactly: vi.fn() }, + CdkAppMultiContext: class { + constructor(appDirectory: string, commandlineContext?: Record) { + contextStoreCalls.push({ appDirectory, commandlineContext }); + } + }, +})); + +// Path is resolved at module load, so the read is mocked rather than redirected. +vi.mock('../../../../lib/schemas/io/global-config', () => ({ + readGlobalConfig: readGlobalConfigMock, +})); + +const PROJECT_DIR = '/tmp/does-not-exist/agentcore/cdk'; + +/** The `props` object handed to `Toolkit.fromCdkApp` on the most recent initialize(). */ +function lastFromCdkAppProps(): Record { + return fromCdkApp.mock.calls.at(-1)?.[1] as Record; +} + +describe('CdkToolkitWrapper permissions boundary wiring', () => { + beforeEach(() => { + vi.clearAllMocks(); + contextStoreCalls.length = 0; + delete process.env[PERMISSIONS_BOUNDARY_ENV_VAR]; + fromCdkApp.mockResolvedValue({}); + readGlobalConfigMock.mockResolvedValue({ success: true, config: {} }); + }); + + it('leaves the default context store in place when no boundary is configured', async () => { + await new CdkToolkitWrapper({ projectDir: PROJECT_DIR }).initialize(); + + expect(lastFromCdkAppProps().contextStore).toBeUndefined(); + expect(contextStoreCalls).toHaveLength(0); + }); + + it('layers the boundary onto the app context store as a policy name', async () => { + await new CdkToolkitWrapper({ + projectDir: PROJECT_DIR, + permissionsBoundary: 'AgentCoreExecutionRoleBoundary', + }).initialize(); + + expect(lastFromCdkAppProps().contextStore).toBeDefined(); + expect(contextStoreCalls).toEqual([ + { + appDirectory: PROJECT_DIR, + commandlineContext: { + [CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { name: 'AgentCoreExecutionRoleBoundary' }, + }, + }, + ]); + }); + + it('passes a boundary ARN through as the arn form', async () => { + const arn = 'arn:aws:iam::111122223333:policy/AgentCoreExecutionRoleBoundary'; + + await new CdkToolkitWrapper({ projectDir: PROJECT_DIR, permissionsBoundary: arn }).initialize(); + + expect(contextStoreCalls[0]?.commandlineContext).toEqual({ + [CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { arn }, + }); + }); + + it('picks the boundary up from the environment', async () => { + process.env[PERMISSIONS_BOUNDARY_ENV_VAR] = 'FromEnvBoundary'; + + await new CdkToolkitWrapper({ projectDir: PROJECT_DIR }).initialize(); + + expect(contextStoreCalls[0]?.commandlineContext).toEqual({ + [CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { name: 'FromEnvBoundary' }, + }); + }); + + it('picks the boundary up from the machine global config', async () => { + readGlobalConfigMock.mockResolvedValue({ success: true, config: { permissionsBoundary: 'FromGlobalBoundary' } }); + + await new CdkToolkitWrapper({ projectDir: PROJECT_DIR }).initialize(); + + expect(contextStoreCalls[0]?.commandlineContext).toEqual({ + [CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { name: 'FromGlobalBoundary' }, + }); + }); +}); + +describe('CdkToolkitWrapper deploy error rewriting', () => { + const REQUIRED_ARN = 'arn:aws:iam::111122223333:policy/OrgBoundary'; + const denial = + 'is not authorized to perform: iam:CreateRole on resource: arn:aws:iam::111122223333:role/Foo ' + + `with an explicit deny in a permissions boundary: ${REQUIRED_ARN}`; + + beforeEach(() => { + vi.clearAllMocks(); + contextStoreCalls.length = 0; + toolkitProps.length = 0; + delete process.env[PERMISSIONS_BOUNDARY_ENV_VAR]; + fromCdkApp.mockResolvedValue({}); + readGlobalConfigMock.mockResolvedValue({ success: true, config: {} }); + }); + + it('turns a boundary denial into PermissionsBoundaryRequiredError', async () => { + deploy.mockRejectedValue(new Error(denial)); + const wrapper = new CdkToolkitWrapper({ projectDir: PROJECT_DIR }); + await wrapper.initialize(); + + await expect(wrapper.deploy()).rejects.toBeInstanceOf(PermissionsBoundaryRequiredError); + }); + + it('reports the mismatch when a boundary was already applied', async () => { + deploy.mockRejectedValue(new Error(denial)); + const wrapper = new CdkToolkitWrapper({ projectDir: PROJECT_DIR, permissionsBoundary: 'WrongBoundary' }); + await wrapper.initialize(); + + await expect(wrapper.deploy()).rejects.toThrow(/Applied: {2}WrongBoundary/); + }); + + it('leaves unrelated deploy failures alone', async () => { + const original = new Error('stack is in ROLLBACK_COMPLETE state'); + deploy.mockRejectedValue(original); + const wrapper = new CdkToolkitWrapper({ projectDir: PROJECT_DIR }); + await wrapper.initialize(); + + await expect(wrapper.deploy()).rejects.toBe(original); + }); + + // What actually happens against a boundary-enforcing account: the reason arrives only as a + // progress message, then the toolkit throws NoStack with no cause. + const progressMessage = { + code: 'CDK_TOOLKIT_I5502', + level: 'info', + message: + 'AgentCore-proj-default | 1/5 | CREATE_FAILED | AWS::IAM::Role | ExecutionRole is not authorized to ' + + 'perform: iam:CreateRole on resource: arn:aws:iam::111122223333:role/Foo with an explicit deny in a ' + + `permissions boundary: ${REQUIRED_ARN}`, + }; + + /** A deploy that reports the denial as progress, then fails the way CloudFormation does. */ + function deployReportingDenialThenNoStack(notifyDuringDeploy: boolean) { + deploy.mockImplementation(async () => { + if (notifyDuringDeploy) { + await toolkitProps.at(-1)?.ioHost?.notify(progressMessage); + } + throw new Error('NoStack: CloudFormationStack object does not hold a stack'); + }); + } + + it('recognizes a denial seen on a progress message when the error itself says NoStack', async () => { + const notify = vi.fn<(msg: unknown) => Promise>().mockResolvedValue(undefined); + const wrapper = new CdkToolkitWrapper({ + projectDir: PROJECT_DIR, + ioHost: { notify, requestResponse: vi.fn() } as never, + }); + await wrapper.initialize(); + deployReportingDenialThenNoStack(true); + + await expect(wrapper.deploy()).rejects.toBeInstanceOf(PermissionsBoundaryRequiredError); + // The wrapper must stay transparent: the caller's host still receives every message. + expect(notify).toHaveBeenCalledWith(progressMessage); + }); + + it('does not pin an earlier deploy′s denial on a later unrelated failure', async () => { + const wrapper = new CdkToolkitWrapper({ + projectDir: PROJECT_DIR, + ioHost: { notify: vi.fn().mockResolvedValue(undefined), requestResponse: vi.fn() } as never, + }); + await wrapper.initialize(); + + deployReportingDenialThenNoStack(true); + await expect(wrapper.deploy()).rejects.toBeInstanceOf(PermissionsBoundaryRequiredError); + + // Second deploy reports no denial; the failure must surface as itself. + deployReportingDenialThenNoStack(false); + await expect(wrapper.deploy()).rejects.not.toBeInstanceOf(PermissionsBoundaryRequiredError); + }); + + it('leaves the toolkit default host in place when the caller supplies none', async () => { + await new CdkToolkitWrapper({ projectDir: PROJECT_DIR }).initialize(); + + expect(toolkitProps.at(-1)?.ioHost).toBeUndefined(); + }); +}); diff --git a/src/cli/cdk/toolkit-lib/types.ts b/src/cli/cdk/toolkit-lib/types.ts index 261e01712..92a883b7b 100644 --- a/src/cli/cdk/toolkit-lib/types.ts +++ b/src/cli/cdk/toolkit-lib/types.ts @@ -178,6 +178,13 @@ export interface CdkToolkitWrapperOptions { * Without this, the toolkit falls back to AWS_REGION env var or us-east-1. */ region?: string; + + /** + * IAM permissions boundary (policy name or policy ARN) to attach to every IAM role in the + * synthesized stacks. Overrides `AGENTCORE_PERMISSIONS_BOUNDARY` and + * `iam.permissionsBoundary` in agentcore.json. + */ + permissionsBoundary?: string; } export interface StackSelectionOptions { diff --git a/src/cli/cdk/toolkit-lib/wrapper.ts b/src/cli/cdk/toolkit-lib/wrapper.ts index e3b9158a9..5d54ccbe6 100644 --- a/src/cli/cdk/toolkit-lib/wrapper.ts +++ b/src/cli/cdk/toolkit-lib/wrapper.ts @@ -1,12 +1,21 @@ import { CONFIG_DIR } from '../../../lib'; +import { permissionsBoundaryCdkContext } from '../../aws/permissions-boundary'; import { CDK_APP_ENTRY, CDK_PROJECT_DIR } from '../../constants'; import { isChangesetInProgressError } from '../../errors'; +import { + isPermissionsBoundaryDenial, + readPermissionsBoundary, + rewriteIfPermissionsBoundaryRequired, +} from '../permissions-boundary'; import type { CdkToolkitWrapperOptions, DeployOptions, DestroyOptions, DiffOptions, ListOptions } from './types'; import { BaseCredentials, BootstrapEnvironments, BootstrapStackParameters, + CdkAppMultiContext, type ICloudAssemblySource, + type IIoHost, + type IoMessage, Toolkit, } from '@aws-cdk/toolkit-lib'; import * as path from 'node:path'; @@ -67,6 +76,10 @@ export class CdkToolkitWrapper { private synthResult: SynthResult | null = null; private synthesizedAssembly: DisposableAssembly | null = null; private synthesizedAssemblyDir: string | null = null; + /** Boundary resolved during initialize(), kept so deploy failures can report a mismatch. */ + private appliedPermissionsBoundary: string | undefined; + /** Boundary denial seen on a progress message; see wrapIoHostForBoundaryDenials. */ + private observedPermissionsBoundaryDenial: string | undefined; constructor(options: CdkToolkitWrapperOptions = {}) { this.projectDir = options.projectDir ?? path.join(process.cwd(), CONFIG_DIR, CDK_PROJECT_DIR); @@ -103,10 +116,23 @@ export class CdkToolkitWrapper { : undefined; this.toolkit = new Toolkit({ - ioHost: this.options.ioHost, + ioHost: this.wrapIoHostForBoundaryDenials(this.options.ioHost), sdkConfig, }); + // Attach the project's permissions boundary through CDK context. aws-cdk-lib turns this + // into a stack-wide aspect over AWS::IAM::Role / AWS::IAM::User, which reaches roles + // created inside the @aws/agentcore-cdk L3 constructs without those constructs needing + // to expose a prop. Only supplied when configured, so the default context store + // (cdk.json + cdk.context.json) is left untouched otherwise. + this.appliedPermissionsBoundary = await readPermissionsBoundary( + this.projectDir, + this.options.permissionsBoundary + ); + const permissionsBoundaryContext = this.appliedPermissionsBoundary + ? permissionsBoundaryCdkContext(this.appliedPermissionsBoundary) + : undefined; + // The vended CDK app (dist/bin/cdk.js) runs as a child process. Forward the region // override through the child env when present. The toolkit overlays this on top of // process.env, so PATH/AWS_PROFILE are preserved. @@ -115,10 +141,41 @@ export class CdkToolkitWrapper { env: { ...(region && { AWS_REGION: region, AWS_DEFAULT_REGION: region }), }, + // CdkAppMultiContext is what toolkit-lib installs by default for fromCdkApp; passing it + // explicitly is the only way to layer extra context on top of the app's own sources. + ...(permissionsBoundaryContext && { + contextStore: new CdkAppMultiContext(this.projectDir, permissionsBoundaryContext), + }), }); }); } + /** + * Pass messages through untouched while watching for a permissions-boundary denial. + * + * When a role create is denied, CloudFormation rolls the stack back and the toolkit throws + * `NoStack: CloudFormationStack object does not hold a stack` with no cause — the actual + * reason only ever appears on a `CDK_TOOLKIT_I5502` progress message. Capturing it here is + * what lets deploy() report something actionable instead of that opaque error. + * + * Returns the host unchanged when none was supplied, so the toolkit keeps its own default. + */ + private wrapIoHostForBoundaryDenials(ioHost: IIoHost | undefined): IIoHost | undefined { + if (!ioHost) { + return undefined; + } + return { + notify: async (msg: IoMessage) => { + const text = typeof msg.message === 'string' ? msg.message : ''; + if (!this.observedPermissionsBoundaryDenial && isPermissionsBoundaryDenial(text)) { + this.observedPermissionsBoundaryDenial = text; + } + return ioHost.notify(msg); + }, + requestResponse: msg => ioHost.requestResponse(msg), + }; + } + /** * Ensure the toolkit is initialized. */ @@ -242,6 +299,10 @@ export class CdkToolkitWrapper { const { toolkit } = this.ensureInitialized(); const source = await this.getSourceForOperation(); + // Scope the captured denial to this call, so a denial from an earlier deploy on the same + // wrapper cannot be pinned on an unrelated later failure. + this.observedPermissionsBoundaryDenial = undefined; + const maxRetries = 3; const baseDelayMs = 5000; // 5 seconds base delay let lastError: Error | null = null; @@ -250,7 +311,13 @@ export class CdkToolkitWrapper { try { return await withErrorContext('deploy', () => toolkit.deploy(source, { stacks: options.stacks })); } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); + // Rewrite here rather than at the CLI/TUI call sites: both funnel through this method. + // The denial may be on the thrown error or only on a progress message we captured. + const rewritten = rewriteIfPermissionsBoundaryRequired(err, { + appliedBoundary: this.appliedPermissionsBoundary, + observedDenial: this.observedPermissionsBoundaryDenial, + }); + lastError = rewritten instanceof Error ? rewritten : new Error(String(rewritten)); // Only retry on changeset-in-progress errors if (isChangesetInProgressError(err) && attempt < maxRetries - 1) { diff --git a/src/cli/commands/deploy/__tests__/utils.test.ts b/src/cli/commands/deploy/__tests__/utils.test.ts index 256eed986..ff28bed43 100644 --- a/src/cli/commands/deploy/__tests__/utils.test.ts +++ b/src/cli/commands/deploy/__tests__/utils.test.ts @@ -1,8 +1,34 @@ import type { AgentCoreProjectSpec } from '../../../../schema'; +import { PERMISSIONS_BOUNDARY_ENV_VAR } from '../../../constants'; import { computeDeployAttrs } from '../utils.js'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Keep the boundary attribute independent of whatever ~/.agentcore/config.json holds locally. +const { readGlobalConfigSyncMock } = vi.hoisted(() => ({ + readGlobalConfigSyncMock: vi.fn(() => ({})), +})); + +vi.mock('../../../../lib/schemas/io/global-config', () => ({ + readGlobalConfigSync: readGlobalConfigSyncMock, +})); describe('computeDeployAttrs', () => { + const savedBoundaryEnv = process.env[PERMISSIONS_BOUNDARY_ENV_VAR]; + + beforeEach(() => { + // The boundary attribute reads the environment; keep it out of the assertions below. + delete process.env[PERMISSIONS_BOUNDARY_ENV_VAR]; + readGlobalConfigSyncMock.mockReturnValue({}); + }); + + afterEach(() => { + if (savedBoundaryEnv === undefined) { + delete process.env[PERMISSIONS_BOUNDARY_ENV_VAR]; + } else { + process.env[PERMISSIONS_BOUNDARY_ENV_VAR] = savedBoundaryEnv; + } + }); + it('computes counts from a populated spec', () => { const projectSpec = { runtimes: [{}, {}], @@ -26,9 +52,24 @@ describe('computeDeployAttrs', () => { policy_engine_count: 2, policy_count: 3, deploy_mode: 'diff', + permissions_boundary: false, }); }); + it('flags an explicitly configured permissions boundary', () => { + const projectSpec = { + iam: { permissionsBoundary: 'AgentCoreExecutionRoleBoundary' }, + } as unknown as Partial; + + expect(computeDeployAttrs(projectSpec, 'deploy').permissions_boundary).toBe(true); + }); + + it('flags a boundary that comes from the machine global config', () => { + readGlobalConfigSyncMock.mockReturnValue({ permissionsBoundary: 'AgentCoreExecutionRoleBoundary' }); + + expect(computeDeployAttrs({}, 'deploy').permissions_boundary).toBe(true); + }); + it('returns zeros for empty spec', () => { expect(computeDeployAttrs({}, 'deploy')).toEqual({ runtime_count: 0, @@ -42,6 +83,7 @@ describe('computeDeployAttrs', () => { policy_engine_count: 0, policy_count: 0, deploy_mode: 'deploy', + permissions_boundary: false, }); }); diff --git a/src/cli/commands/deploy/utils.ts b/src/cli/commands/deploy/utils.ts index d6362e114..d5aa58546 100644 --- a/src/cli/commands/deploy/utils.ts +++ b/src/cli/commands/deploy/utils.ts @@ -1,4 +1,6 @@ +import { readGlobalConfigSync } from '../../../lib/schemas/io/global-config'; import type { AgentCoreProjectSpec } from '../../../schema'; +import { resolvePermissionsBoundary } from '../../aws'; import type { DeployMode } from '../../telemetry/schemas/common-shapes'; export const DEFAULT_DEPLOY_ATTRS = { @@ -30,5 +32,12 @@ export function computeDeployAttrs(projectSpec: Partial, m policy_engine_count: policyEngines.length, policy_count: policyEngines.reduce((sum, pe) => sum + (pe.policies ?? []).length, 0), deploy_mode: mode, + // Records adoption only — the boundary value itself is customer-identifying and is not emitted. + permissions_boundary: Boolean( + resolvePermissionsBoundary({ + configured: projectSpec.iam?.permissionsBoundary, + global: readGlobalConfigSync().permissionsBoundary, + }) + ), }; } diff --git a/src/cli/constants.ts b/src/cli/constants.ts index c5a428e6d..a2028c60a 100644 --- a/src/cli/constants.ts +++ b/src/cli/constants.ts @@ -96,6 +96,25 @@ export const CDK_PROJECT_DIR = 'cdk'; */ export const CDK_APP_ENTRY = 'dist/bin/cdk.js'; +/** ARN scheme prefix, partition-agnostic. */ +export const ARN_PREFIX = 'arn:'; + +/** + * CDK context key that makes aws-cdk-lib attach a permissions boundary to every + * `AWS::IAM::Role` and `AWS::IAM::User` in a stack. + * + * Mirrors `PERMISSIONS_BOUNDARY_CONTEXT_KEY` from aws-cdk-lib. It is duplicated here because + * aws-cdk-lib is a dev/peer dependency of the CLI (it belongs to the vended CDK project, which + * runs as a separate process) and must not be imported at CLI runtime. + */ +export const CDK_PERMISSIONS_BOUNDARY_CONTEXT_KEY = '@aws-cdk/core:permissionsBoundary'; + +/** + * Overrides `iam.permissionsBoundary` from agentcore.json. Useful in CI, and for accounts + * whose boundary ARN differs from the one committed to the project config. + */ +export const PERMISSIONS_BOUNDARY_ENV_VAR = 'AGENTCORE_PERMISSIONS_BOUNDARY'; + /** * Max length AWS BedrockAgentCore allows for a runtime name (combined projectName_agentName). */ diff --git a/src/cli/operations/jobs/ab-test/__tests__/resolve.test.ts b/src/cli/operations/jobs/ab-test/__tests__/resolve.test.ts index df678500b..7c7109e8b 100644 --- a/src/cli/operations/jobs/ab-test/__tests__/resolve.test.ts +++ b/src/cli/operations/jobs/ab-test/__tests__/resolve.test.ts @@ -1,6 +1,7 @@ import type { AgentCoreProjectSpec } from '../../../../../schema'; +import { PERMISSIONS_BOUNDARY_ENV_VAR } from '../../../../constants'; import { getOrCreateABTestRole, resolveRuntimeTargetNames } from '../resolve'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { mockIamSend } = vi.hoisted(() => ({ mockIamSend: vi.fn(), @@ -31,6 +32,12 @@ vi.mock('../../../../aws/account', () => ({ getCredentialProvider: vi.fn().mockReturnValue({}), })); +// The boundary falls back to the machine config, so leaving this unmocked would make the +// assertions depend on whatever ~/.agentcore/config.json holds on the developer's machine. +vi.mock('../../../../../lib/schemas/io/global-config', () => ({ + readGlobalConfig: vi.fn().mockResolvedValue({ success: true, config: {} }), +})); + const accountId = '123456789012'; const roleArn = `arn:aws:iam::${accountId}:role/AgentCore-Test-ABTestExperiment`; @@ -79,8 +86,20 @@ function entityAlreadyExistsError(): Error { } describe('getOrCreateABTestRole', () => { + const savedBoundaryEnv = process.env[PERMISSIONS_BOUNDARY_ENV_VAR]; + beforeEach(() => { vi.clearAllMocks(); + // Same reason as the global-config mock above: the boundary also reads the environment. + delete process.env[PERMISSIONS_BOUNDARY_ENV_VAR]; + }); + + afterEach(() => { + if (savedBoundaryEnv === undefined) { + delete process.env[PERMISSIONS_BOUNDARY_ENV_VAR]; + } else { + process.env[PERMISSIONS_BOUNDARY_ENV_VAR] = savedBoundaryEnv; + } }); it('creates a new role and applies its inline permissions policy', async () => { @@ -90,6 +109,34 @@ describe('getOrCreateABTestRole', () => { expect(mockIamSend).toHaveBeenCalledTimes(2); }); + it('omits PermissionsBoundary when the project configures none', async () => { + mockIamSend.mockResolvedValueOnce({ Role: { Arn: roleArn } }).mockResolvedValueOnce({}); + + await getOrCreateABTestRole(roleOptions()); + + const createInput = (mockIamSend.mock.calls[0]?.[0] as { input: Record }).input; + expect(createInput.PermissionsBoundary).toBeUndefined(); + }); + + it('expands a boundary policy name into an ARN for CreateRole', async () => { + mockIamSend.mockResolvedValueOnce({ Role: { Arn: roleArn } }).mockResolvedValueOnce({}); + + await getOrCreateABTestRole({ ...roleOptions(), permissionsBoundary: 'AgentCoreExecutionRoleBoundary' }); + + const createInput = (mockIamSend.mock.calls[0]?.[0] as { input: Record }).input; + expect(createInput.PermissionsBoundary).toBe(`arn:aws:iam::${accountId}:policy/AgentCoreExecutionRoleBoundary`); + }); + + it('passes a boundary ARN through unchanged', async () => { + const boundaryArn = `arn:aws:iam::${accountId}:policy/Custom/Boundary`; + mockIamSend.mockResolvedValueOnce({ Role: { Arn: roleArn } }).mockResolvedValueOnce({}); + + await getOrCreateABTestRole({ ...roleOptions(), permissionsBoundary: boundaryArn }); + + const createInput = (mockIamSend.mock.calls[0]?.[0] as { input: Record }).input; + expect(createInput.PermissionsBoundary).toBe(boundaryArn); + }); + it('reuses an existing role when its trust policy matches', async () => { mockIamSend .mockRejectedValueOnce(entityAlreadyExistsError()) diff --git a/src/cli/operations/jobs/ab-test/handler.ts b/src/cli/operations/jobs/ab-test/handler.ts index 379226781..fe7a1c1d5 100644 --- a/src/cli/operations/jobs/ab-test/handler.ts +++ b/src/cli/operations/jobs/ab-test/handler.ts @@ -138,6 +138,7 @@ export const abTestHandler: ABTestHandler = { projectName: projectSpec.name, testName: opts.name, gatewayArn, + permissionsBoundary: projectSpec.iam?.permissionsBoundary, }); roleCreatedByCli = true; } diff --git a/src/cli/operations/jobs/ab-test/resolve.ts b/src/cli/operations/jobs/ab-test/resolve.ts index 8a1f0bd25..ca8480a07 100644 --- a/src/cli/operations/jobs/ab-test/resolve.ts +++ b/src/cli/operations/jobs/ab-test/resolve.ts @@ -5,11 +5,13 @@ * Extracted from the legacy post-deploy-ab-tests.ts so the AB-test job handler's create() * can own role + ARN resolution at start time (the config-as-code deploy path is removed). */ +import { readGlobalConfig } from '../../../../lib/schemas/io/global-config'; import type { AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema'; import { getCredentialProvider } from '../../../aws/account'; import type { ABTestEvaluationConfig, ABTestVariant } from '../../../aws/agentcore-ab-tests'; import { validateIamRoleTrustPolicy } from '../../../aws/iam'; import { arnPrefix } from '../../../aws/partition'; +import { resolvePermissionsBoundary, toPermissionsBoundaryArn } from '../../../aws/permissions-boundary'; import { CreateRoleCommand, DeleteRoleCommand, @@ -48,6 +50,8 @@ export interface CreateABTestRoleOptions { projectName: string; testName: string; gatewayArn: string; + /** `iam.permissionsBoundary` from agentcore.json (policy name or ARN). */ + permissionsBoundary?: string; /** Injectable propagation delay (tests). */ propagationDelayMs?: number; } @@ -62,6 +66,15 @@ export async function getOrCreateABTestRole(options: CreateABTestRoleOptions): P const accountId = gatewayArn.split(':')[4] ?? '*'; const roleName = generateRoleName(projectName, testName); + // CreateRole needs a fully expanded ARN; unlike CloudFormation it cannot resolve a bare + // policy name. Accounts that force a boundary reject this call outright without it. + const globalRead = await readGlobalConfig(); + const boundary = resolvePermissionsBoundary({ + configured: options.permissionsBoundary, + global: globalRead.success ? globalRead.config.permissionsBoundary : undefined, + }); + const permissionsBoundaryArn = boundary ? toPermissionsBoundaryArn(boundary, { region, accountId }) : undefined; + const trustPolicyDocument = { Version: '2012-10-17', Statement: [ @@ -85,6 +98,7 @@ export async function getOrCreateABTestRole(options: CreateABTestRoleOptions): P RoleName: roleName, AssumeRolePolicyDocument: trustPolicy, Description: `Auto-created execution role for AgentCore AB test: ${testName}`, + ...(permissionsBoundaryArn && { PermissionsBoundary: permissionsBoundaryArn }), Tags: [ { Key: 'agentcore:created-by', Value: 'agentcore-cli' }, { Key: 'agentcore:project-name', Value: projectName }, diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index bca7a0e9a..502663af5 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -146,6 +146,8 @@ const DeployAttrs = safeSchema({ policy_engine_count: Count, policy_count: Count, deploy_mode: DeployModeSchema, + /** Whether an IAM permissions boundary was applied to the deployed roles. */ + permissions_boundary: z.boolean().optional(), dep_sync_outcome: DepSyncOutcome.optional(), dep_sync_changed_count: Count.optional(), dep_sync_migrated: z.boolean().optional(), diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index 2c4064390..07a6a5096 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -129,6 +129,7 @@ export const ErrorName = z.enum([ 'MissingProjectFileError', 'NoProjectError', 'PackagingError', + 'PermissionsBoundaryRequiredError', 'PollExhaustedError', 'PollTimeoutError', 'ResourceNotFoundError', diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index 6186ea39c..1758df97a 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -61,6 +61,51 @@ export class AccessDeniedError extends BaseError { } } +/** + * Error thrown when CloudFormation is denied `iam:CreateRole` because the account requires + * every new role to carry a permissions boundary and none was declared. + * + * Raw CloudFormation reports this as `UnauthorizedTaggingOperation` with the IAM denial buried + * in a nested message, which gives no hint that the fix is a one-line config change. The denial + * does name the boundary the account expects, so the remedy can be stated exactly. + */ +export class PermissionsBoundaryRequiredError extends BaseError { + /** Boundary ARN the account demands, when it could be read out of the denial. */ + readonly requiredBoundaryArn?: string; + + constructor(requiredBoundaryArn?: string, appliedBoundary?: string, options?: BaseErrorOptions) { + const target = requiredBoundaryArn ?? ''; + // The opening sentence has to follow the branch: saying the role "had none" would be plainly + // wrong when a boundary was applied and merely rejected as the wrong one. + const lines = appliedBoundary + ? [ + 'This account requires every new IAM role to carry a specific permissions boundary, ' + + 'and CloudFormation was denied iam:CreateRole because the boundary this deploy ' + + 'applied is not the one it requires.', + '', + ` Applied: ${appliedBoundary}`, + ` Required: ${target}`, + '', + 'Update the configured value and re-run `agentcore deploy`.', + ] + : [ + 'This account requires every new IAM role to carry a permissions boundary, and ' + + 'CloudFormation was denied iam:CreateRole because the role it tried to create had none.', + '', + 'Set it for every project on this machine:', + ` agentcore config permissionsBoundary ${target}`, + '', + 'Or commit it with the project, in agentcore/agentcore.json:', + ` "iam": { "permissionsBoundary": "${target}" }`, + '', + 'Then re-run `agentcore deploy`.', + ]; + lines.push('', 'See docs/PERMISSIONS.md ("Hardening with permission boundaries") for details.'); + super(lines.join('\n'), { defaultSource: 'user', ...options }); + this.requiredBoundaryArn = requiredBoundaryArn; + } +} + /** * Error thrown when a secret value cannot be encrypted before writing to disk * (e.g. the machine encryption key could not be created/read). diff --git a/src/lib/schemas/io/global-config.ts b/src/lib/schemas/io/global-config.ts index a7b92657c..9cfb3649b 100644 --- a/src/lib/schemas/io/global-config.ts +++ b/src/lib/schemas/io/global-config.ts @@ -17,6 +17,15 @@ const GlobalConfigSchemaStrict = z installationId: z.string().uuid().optional(), uvDefaultIndex: z.string().optional(), uvIndex: z.string().optional(), + /** + * IAM policy name or ARN attached as the permissions boundary of every role the CLI + * creates, for all projects on this machine. Lives here rather than only in + * agentcore.json because a boundary is a property of the account you deploy into, not of + * the project: it is set once by whoever configured the account, and committing it would + * break teammates deploying into an account without that policy. A project-level + * `iam.permissionsBoundary` overrides this. + */ + permissionsBoundary: z.string().optional(), disableDependencyManagement: z.boolean().optional(), disableTransactionSearch: z.boolean().optional(), transactionSearchIndexPercentage: z.number().int().min(0).max(100).optional(), diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index d4b9b85a9..12e9bcd04 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -12,6 +12,7 @@ interface AgentCoreProjectSpec { version: number; // integer @min 1 managedBy: 'CDK'; // default 'CDK' tags?: Tags; + iam?: ProjectIamSettings; runtimes: AgentEnvSpec[]; // default [], unique by name memories: Memory[]; // default [], unique by name knowledgeBases: KnowledgeBase[]; // default [], unique by name @@ -31,6 +32,11 @@ interface AgentCoreProjectSpec { } type Tags = Record; // @max 50 entries; keys @min 1 @max 128; values @max 256 +interface ProjectIamSettings { + // IAM policy name or policy ARN attached as the permissions boundary of every role created + // for this project. A bare name resolves against each target's partition and account. + permissionsBoundary?: string; +} type BuildType = 'CodeZip' | 'Container'; type PythonRuntime = 'PYTHON_3_10' | 'PYTHON_3_11' | 'PYTHON_3_12' | 'PYTHON_3_13' | 'PYTHON_3_14'; type NodeRuntime = 'NODE_18' | 'NODE_20' | 'NODE_22'; diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index ecad52db4..08fe40e1a 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -408,6 +408,46 @@ describe('AgentCoreProjectSpecSchema', () => { } }); + it('leaves iam undefined when absent', () => { + const result = AgentCoreProjectSpecSchema.safeParse(minimalProject); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.iam).toBeUndefined(); + expect('iam' in result.data).toBe(false); + } + }); + + it('accepts iam.permissionsBoundary as a policy name or ARN in any partition', () => { + const accepted = [ + 'AgentCoreExecutionRoleBoundary', + 'boundary+with=allowed,chars.@-_', + 'arn:aws:iam::111122223333:policy/AgentCoreExecutionRoleBoundary', + 'arn:aws-cn:iam::111122223333:policy/AgentCoreExecutionRoleBoundary', + 'arn:aws-us-gov:iam::111122223333:policy/path/AgentCoreExecutionRoleBoundary', + 'arn:aws:iam::aws:policy/PowerUserBoundary', + ]; + + for (const permissionsBoundary of accepted) { + const result = AgentCoreProjectSpecSchema.safeParse({ ...minimalProject, iam: { permissionsBoundary } }); + expect(result.success, permissionsBoundary).toBe(true); + } + }); + + it('rejects malformed permissions boundaries and unknown iam keys', () => { + const rejected: unknown[] = [ + { permissionsBoundary: '' }, + { permissionsBoundary: 'has spaces' }, + { permissionsBoundary: 'arn:aws:iam::111122223333:role/NotAPolicy' }, + { permissionsBoundary: 'a'.repeat(129) }, + { permissionsBoundary: 'Boundary', rolePath: '/custom/' }, + ]; + + for (const iam of rejected) { + const result = AgentCoreProjectSpecSchema.safeParse({ ...minimalProject, iam }); + expect(result.success, JSON.stringify(iam)).toBe(false); + } + }); + it('leaves payments undefined when absent (optional, non-breaking round-trip)', () => { // payments is .optional(), NOT .default([]) — parsing a project without a // payments key must NOT materialize `payments: []`, so re-serializing an diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index eaf62dd13..8e0ce27bc 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -410,6 +410,43 @@ export type HarnessRegistryEntry = z.infer; const BUILTIN_EVALUATOR_PREFIX = 'Builtin.'; const ARN_PREFIX = 'arn:'; +/** + * An IAM policy name or a full IAM policy ARN. + * + * Names follow the CreatePolicy API character set (`[\w+=,.@-]`, max 128) and cannot contain + * `:`, so one field accepts both forms unambiguously. The ARN branch is partition-agnostic and + * keeps the account segment loose so customer-managed (`iam::111122223333:policy/Name`), + * AWS-managed (`iam::aws:policy/Name`) and pseudo-parameter (`iam::${AWS::AccountId}:policy/Name`) + * forms all pass. A single regex (rather than a refinement) keeps the constraint visible in the + * generated JSON Schema for editor validation. + */ +const IAM_POLICY_NAME_OR_ARN_PATTERN = /^(?:arn:[^:]+:iam::[^:]*:policy\/.+|[\w+=,.@-]{1,128})$/; + +/** + * Project-wide IAM settings applied to every role the CLI creates. + */ +export const ProjectIamSettingsSchema = z + .object({ + /** + * Permissions boundary attached to every IAM role created for this project. + * + * Accepts a policy name or a full policy ARN. A bare name is resolved against the + * deployment target's own partition and account, so one value works across targets. + * Required in accounts where an organization boundary denies `iam:CreateRole` unless + * the new role carries a boundary. + */ + permissionsBoundary: z + .string() + .regex( + IAM_POLICY_NAME_OR_ARN_PATTERN, + 'permissionsBoundary must be an IAM policy name (e.g. "AgentCoreExecutionRoleBoundary") or a policy ARN (e.g. "arn:aws:iam::111122223333:policy/AgentCoreExecutionRoleBoundary")' + ) + .optional(), + }) + .strict(); + +export type ProjectIamSettings = z.infer; + export const AgentCoreProjectSpecSchema = z .object({ $schema: z.string().optional(), @@ -417,6 +454,7 @@ export const AgentCoreProjectSpecSchema = z version: z.number().int().min(1), managedBy: ManagedBySchema, tags: TagsSchema.optional(), + iam: ProjectIamSettingsSchema.optional(), runtimes: z .array(AgentEnvSpecSchema)