This document covers how to write, manage, and extend Cedar policies for AgentCore Gateway. For the identity propagation architecture and component setup, see Identity Propagation & Cedar Policy Guide.
Cedar policies reference JWT claims via principal.getTag("claim_name"). These claims come in two categories:
Custom claims are injected by the V3 Pre-Token Lambda via claimsToAddOrOverride. They are not part of the standard JWT/OIDC claim set — they are defined based on the application's access control needs.
Claims in this demo:
| Claim | Purpose | Example Value |
|---|---|---|
user_id |
Authenticated user's identity | "yourname@company.com" |
department |
User's organizational unit | "finance" |
role |
User's permission level | "admin" |
Additional custom claim examples:
| Claim | Use Case | Cedar Usage |
|---|---|---|
tenant_id |
Multi-tenant isolation | principal.getTag("tenant_id") == "example-corp" |
clearance_level |
Tiered data access | principal.getTag("clearance_level") == "top-level" |
region |
Geo-restricted access | principal.getTag("region") == "us-east-1" |
runtime_env |
Runtime-level isolation | principal.getTag("runtime_env") == "production" |
To add a custom claim: inject it in the Pre-Token Lambda's claimsToAddOrOverride dict, then reference it in Cedar via principal.getTag("claim_name"). No Gateway configuration change is needed — the CUSTOM_JWT authorizer maps all JWT claims to Cedar tags automatically.
Standard claims are automatically included in every token by Cognito. They cannot be overridden by the Pre-Token Lambda.
| Claim | Description | Modifiable? |
|---|---|---|
sub |
Subject identifier (app client ID for M2M) | No |
iss |
Token issuer (Cognito user pool URL) | No |
client_id |
The app client ID | No |
token_use |
Always "access" |
No |
scope |
OAuth scopes granted | No |
exp / iat / jti |
Token timing and ID | No |
Standard claims are also accessible via principal.getTag() in Cedar but are typically used for infrastructure-level checks rather than business logic.
| Data | Why Not Available | Alternative |
|---|---|---|
| Request headers / IP | Not exposed to Cedar | N/A (not supported) |
| Runtime ARN | Not in Cedar schema | Inject runtime_env via Pre-Token Lambda (see Runtime-Level Access Control) |
| Tool input parameters | Not a claim | Use context.input.<field> in Cedar |
gateway/policies/policy.cedar — edit this file and run cdk deploy to apply changes. The Custom Resource Lambda detects the change and updates the policy in-place without recreating the Policy Engine.
Cedar action names follow the format: <TargetName>___<tool_name> (triple underscore).
- TargetName comes from the
CfnGatewayTargetname inbackend-stack.ts(e.g.,sample-tool-target) - tool_name comes from
tool_spec.json(e.g.,text_analysis_tool) - Combined:
sample-tool-target___text_analysis_tool
These are case-sensitive. A mismatch silently denies all requests even when the policy logic looks correct.
Cedar is deny-by-default: if no permit statement matches a request, it is automatically denied. An explicit forbid statement is not needed to block access — simply omit the department from the permit's conditions.
For example, to deny guests, remove "guest" from the department list. No forbid statement is required.
The AgentCore Policy Engine enforces authorization at two points in the tool lifecycle:
When the Runtime calls tools/list on the Gateway, the Policy Engine evaluates every tool against the caller's identity using PartiallyAuthorizeActions. Tools that the caller is not permitted to use are removed from the response. The agent never sees them.
Agent → Runtime → Gateway tools/list → Policy Engine (PartiallyAuthorizeActions)
↓
Evaluates each tool against principal's claims
↓
Returns ONLY permitted tools
↓
Agent receives filtered tool list
Effect: If a user with department=guest calls tools/list while Version 2 (guest denied) is active, the text_analysis_tool will NOT appear in the response. The agent has no knowledge the tool exists and will not attempt to call it.
When the agent calls a specific tool, the Policy Engine evaluates the request with full context — including the tool's input parameters (context.input). This is a stricter evaluation than discovery because it has access to the actual request payload.
Agent → Runtime → Gateway tools/call → Policy Engine (AuthorizeAction)
↓
Evaluates principal claims + context.input
↓
Allow → execute tool
Deny → return authorization error
Why both? A tool might pass discovery filtering (the user is generally allowed to use it) but fail at execution time due to input-specific conditions. For example:
// User can discover the refund tool (passes tools/list filtering)
// But execution is denied if amount > 1000 (fails tools/call check)
permit(
principal is AgentCore::OAuthUser,
action == AgentCore::Action::"billing-target___process_refund",
resource == AgentCore::Gateway::"{{GATEWAY_ARN}}"
)
when {
principal.hasTag("department") &&
principal.getTag("department") == "finance" &&
context.input.amount < 1000
};
In this example, a finance user would see process_refund in tools/list (they're in the finance department), but if they try to process a refund of $5000, the tools/call would be denied because context.input.amount < 1000 fails.
To confirm that denied tools are being filtered from tools/list:
- Enable tracing on both the Runtime and Gateway (see Verifying Policy Decisions via Tracing)
- Trigger a query from the frontend
- In CloudWatch →
aws/spanslog group, filter forPartiallyAuthorizeActions - The span contains:
aws.agentcore.policy.allowed_tools: tools returned to the agentaws.agentcore.policy.denied_tools: tools filtered outaws.agentcore.gateway.policy.mode: should showENFORCE
Verifying at the Runtime level: Add a log line in the agent code to confirm which tools the agent received after Cedar policy filtering. Examples for the two primary agent patterns:
Strands pattern (
patterns/strands-single-agent/basic_agent.py) — add afterAgent()creation:agent = Agent( name="strands_agent", tools=[gateway_client, code_tools.execute_python_securely], ... ) specs = agent.tool_registry.get_all_tool_specs() logger.info(f"[GATEWAY] Raw tool specs: {specs}") return agentWhere to find: CloudWatch → Log groups →
/aws/bedrock-agentcore/runtimes/{runtime_name}→ log streamotel-rt-logs. Search for[GATEWAY] Raw tool specs.LangGraph pattern (
patterns/langgraph-single-agent/langgraph_agent.py) — add aftermcp_client.get_tools():mcp_client = await create_gateway_mcp_client(user_id) tools = await mcp_client.get_tools() logger.info(f"[GATEWAY] Tools loaded: {[t.name for t in tools]}")Where to find: CloudWatch → Log groups →
/aws/bedrock-agentcore/runtimes/{runtime_name}→ log streamotel-rt-logs. Search for[GATEWAY] Tools loaded.
| Stage | API | Evaluation | What Happens on Deny |
|---|---|---|---|
Discovery (tools/list) |
PartiallyAuthorizeActions |
Principal claims only (no input context) | Tool is hidden — agent never sees it |
Execution (tools/call) |
AuthorizeAction |
Principal claims + context.input |
Request rejected — agent gets authorization error |
When adding a new Gateway target and tool:
- Create the new Lambda tool and
CfnGatewayTargetinbackend-stack.ts - Add a new
permitstatement topolicy.cedarwith the correct action name - Run
cdk deploy
Each create_policy call creates one policy containing one Cedar statement. The Custom Resource currently creates a single policy per deploy. To add multiple policies (e.g., separate permit and forbid statements), update the Custom Resource Lambda to call create_policy() once per statement.
AgentCore Gateway validates Cedar policies against an auto-generated schema derived from the Gateway's MCP tool manifest. Policies that reference unsupported fields will fail during creation, causing CloudFormation rollback.
Supported in Cedar policies:
| Element | What Can Be Referenced | Example |
|---|---|---|
principal |
Must be AgentCore::OAuthUser |
principal is AgentCore::OAuthUser |
principal.hasTag() / principal.getTag() |
Any JWT claim mapped by the CUSTOM_JWT authorizer | principal.getTag("department") |
action |
Tool actions in <TargetName>___<tool_name> format |
AgentCore::Action::"sample-tool-target___text_analysis_tool" |
resource |
The Gateway ARN | AgentCore::Gateway::"arn:aws:..." |
context.input |
Tool input parameters as defined in the MCP manifest | context.input.query |
NOT supported in Cedar policies:
| Element | Why |
|---|---|
context.runtime.arn |
Not in the schema — only context.input is available |
| Custom entity types | Cannot define entities outside the AgentCore namespace |
Custom attributes on OAuthUser |
Use hasTag()/getTag() instead of direct property access |
| Request metadata (headers, IP, etc.) | Not exposed to Cedar |
If access control decisions depend on information not available in context.input, inject it as a JWT claim via the Pre-Token Lambda and access it via principal.getTag(). See Runtime-Level Access Control for an example of this pattern.
Cedar is a purpose-built policy language designed for authorization. This section documents what can be expressed in Cedar policies for AgentCore Gateway, with practical examples for each capability.
Already demonstrated in this project:
- Identity-based access (
principal.getTag("department") == "finance") — see Cedar Policy File Version 1 & 2- Multi-value OR conditions (
department == "finance" || department == "engineering") — see Version 1 policyThe capabilities below show additional patterns that can be implemented using the same infrastructure.
Scenario: Finance users can process refunds, but only up to $1000. Refunds above $1000 require a different approval workflow.
How it works: context.input gives Cedar access to the tool's input parameters (as defined in the MCP tool manifest). Conditions can be written against these values. The tool still appears in tools/list for finance users (discovery only checks principal claims), but the $1000 limit is enforced at tools/call time when the actual input is available.
permit(
principal is AgentCore::OAuthUser,
action == AgentCore::Action::"billing-target___process_refund",
resource == AgentCore::Gateway::"{{GATEWAY_ARN}}"
)
when {
principal.hasTag("department") &&
principal.getTag("department") == "finance" &&
context.input.amount < 1000
};
Result:
- Finance user, amount=500 → permitted
- Finance user, amount=5000 → denied (exceeds limit)
- Engineering user, amount=100 → denied (wrong department)
Important: This is where Tool Discovery vs Execution matters most. The tool passes discovery filtering (finance user is generally permitted), but execution is denied when the input violates the condition.
Scenario: Developers can use all read-only tools (list, get, search) but cannot use write tools (create, update, delete).
How it works: Use action in [...] to apply one policy to multiple tools at once, instead of writing separate permit statements for each tool.
permit(
principal is AgentCore::OAuthUser,
action in [
AgentCore::Action::"data-target___list_records",
AgentCore::Action::"data-target___get_record",
AgentCore::Action::"data-target___search_records"
],
resource == AgentCore::Gateway::"{{GATEWAY_ARN}}"
)
when {
principal.hasTag("role") &&
principal.getTag("role") == "developer"
};
Result:
- Developer calls
list_records→ permitted - Developer calls
search_records→ permitted - Developer calls
delete_record→ denied (not in the action list)
Note: Separate
permitstatements can also be written for each tool. Theaction in [...]syntax is a convenience for grouping related tools under the same conditions.
Scenario: All departments are allowed to use a tool, EXCEPT a specific user (e.g., a compromised account) needs to be explicitly blocked regardless of their department.
How it works: forbid statements override permit statements. Cedar's conflict resolution is "forbid wins" — if both a permit and forbid match, the request is denied.
// Allow all departments
permit(
principal is AgentCore::OAuthUser,
action == AgentCore::Action::"sample-tool-target___text_analysis_tool",
resource == AgentCore::Gateway::"{{GATEWAY_ARN}}"
)
when {
principal.hasTag("department")
};
// But explicitly block a specific user
forbid(
principal is AgentCore::OAuthUser,
action == AgentCore::Action::"sample-tool-target___text_analysis_tool",
resource == AgentCore::Gateway::"{{GATEWAY_ARN}}"
)
when {
principal.hasTag("user_id") &&
principal.getTag("user_id") == "compromised-user@example.com"
};
Result:
- Any user with a department → permitted
compromised-user@example.com→ denied (forbid wins over permit)
Note: Cedar's deny-by-default means simply omitting a user/department from the
permitis often sufficient to deny access. Useforbidwhen overriding a broadpermitfor specific cases — such as blocking a compromised user, disabling a tool during an incident, or implementing an emergency shutdown.
Scenario: Only users with an @example.com email domain can access internal tools. External contractors with other email domains are denied.
How it works: Use like with * wildcard for pattern matching on string claim values.
permit(
principal is AgentCore::OAuthUser,
action == AgentCore::Action::"internal-target___internal_tool",
resource == AgentCore::Gateway::"{{GATEWAY_ARN}}"
)
when {
principal.hasTag("user_id") &&
principal.getTag("user_id") like "*@example.com"
};
Result:
alice@example.com→ permittedbob@example.com→ permittedcontractor@external.com→ denied (doesn't match pattern)
Note:
likeonly supports*as a wildcard, which matches zero or more characters of any kind (letters, numbers, symbols, dots, etc.). It does not support regex, single-character wildcards, character classes, or other pattern syntax.
Scenario: Production tools should only be accessible from the production runtime. Staging runtimes should not be able to call production tools even if the user has the right department/role.
How it works: The Pre-Token Lambda maps the Cognito clientId to a runtime_env claim (see Runtime-Level Access Control). Cedar checks both user identity AND runtime environment.
permit(
principal is AgentCore::OAuthUser,
action == AgentCore::Action::"prod-target___production_tool",
resource == AgentCore::Gateway::"{{GATEWAY_ARN}}"
)
when {
principal.hasTag("runtime_env") &&
principal.getTag("runtime_env") == "production" &&
principal.hasTag("department") &&
principal.getTag("department") == "finance"
};
Result:
- Finance user from production runtime → permitted
- Finance user from staging runtime → denied (wrong environment)
- Engineering user from production runtime → denied (wrong department)
| Operator | Meaning | Example |
|---|---|---|
== |
Equals | principal.getTag("role") == "admin" |
!= |
Not equals | principal.getTag("department") != "restricted" |
&& |
AND (both must be true) | condition_a && condition_b |
|| |
OR (either can be true) | value == "a" || value == "b" |
<, >, <=, >= |
Numeric comparison | context.input.amount < 1000 |
in [...] |
Action is one of a set | action in [Action::"a", Action::"b"] |
like |
Wildcard string match | principal.getTag("email") like "*@example.com" |
hasTag() |
Claim exists in token | principal.hasTag("department") |
getTag() |
Get claim value | principal.getTag("department") |
has |
Field/attribute exists | context.input has shippingAddress |
.contains() |
Set membership | ["US", "CA", "MX"].contains(context.input.country) |
| Limitation | Workaround |
|---|---|
| Regular expressions | Use like with * wildcard for simple patterns |
Arithmetic operations (e.g., a + b > c) |
Pre-compute in the Pre-Token Lambda and inject as a claim |
| External data lookups (e.g., query a database) | Resolve in the Pre-Token Lambda and inject as a claim |
| Time-based rules (e.g., "only during business hours") | Inject a time_window claim from the Pre-Token Lambda |
| Array/list membership (e.g., "user in allowed_list") | Use .contains() for hardcoded lists: ["a", "b"].contains(context.input.x). For dynamic lists (loaded from a database), resolve in the Pre-Token Lambda and inject as a boolean claim |
| Request headers, IP address, or network context | Not exposed to Cedar — not available |
Architecture Pattern: When Cedar cannot evaluate something directly (time, external data, complex logic), resolve it in the Pre-Token Lambda and inject the result as a custom claim. Cedar then checks the pre-resolved value. This keeps policies simple, deterministic, and auditable.