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)