Guide · Cedar · AWS Verified Permissions
Cedar Policy Language for MCP Server Authorization
Cedar is a policy language designed for fine-grained, attribute-based authorization decisions — it evaluates in microseconds, is provably safe (no loops, no side effects), and is the language backing AWS Verified Permissions. A Cedar policy has three required head clauses — principal, action, and resource — plus an optional when or unless condition clause. Three subtle rules trip up teams new to Cedar: the in operator checks group membership transitively (if User is in GroupA and GroupA is in GroupB, then principal in MCP::Group::"GroupB" is true — this matters when your tenant hierarchy has sub-groups); attribute access on an absent attribute is a type error that causes the containing policy to be skipped, not to return false — so a policy that checks principal.plan == "free" is silently skipped for principals that have no plan attribute; and forbid beats permit absolutely — there is no policy priority score or weight that can override a forbid match.
TL;DR
Cedar syntax: permit(principal [constraint], action [constraint], resource [constraint]) when { condition };. Effects are permit or forbid. Conditions use &&, ||, !, ==, !=, in, has. Use has before attribute access to guard against absent-attribute type errors. Forbid always beats permit. Use the open-source Cedar CLI to test policies before uploading to AVP.
Policy structure in full
A Cedar policy is a single statement with this grammar:
effect (
principal [is EntityType] [== EntityRef] [in EntityRef],
action [== ActionRef] [in [ActionRef, ...]],
resource [is EntityType] [== EntityRef] [in EntityRef]
)
[when { booleanExpr }]
[unless { booleanExpr }]
;
All three head clauses are required but can use an unscoped wildcard (just the keyword alone) to match any entity of that role. Examples:
// Match any principal, any action, any resource — deny everything (emergency lockdown)
forbid (principal, action, resource);
// Match any MCP::User calling any action on any MCP::Tool
permit (principal is MCP::User, action, resource is MCP::Tool);
// Exact entity reference
permit (
principal == MCP::User::"alice@example.com",
action == MCP::Action::"CallTool",
resource == MCP::Tool::"export_sla_report"
);
The is constraint checks entity type. The == constraint checks exact identity. The in constraint checks group membership (transitive). Combine them with when/unless to express attribute-based rules.
Attribute-based access control (ABAC) patterns
ABAC policies use when conditions that read attributes of the principal, resource, or context instead of hard-coding specific entity IDs. This lets a small number of policies cover a large number of users and resources without creating one policy per (user, tool) pair.
// Pattern 1: Tier-gated tool access
// Free users see free tools; paid users see free + paid tools
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
(principal.plan == "free" && resource.tier == "free") ||
(principal.plan == "author" && resource.tier in ["free", "paid"]) ||
(principal.plan in ["team", "enterprise"])
};
// Pattern 2: Tenant isolation — a user can only access tools from their own tenant
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
principal.tenantId == resource.tenantId
};
// Pattern 3: Context-based time-of-day restriction (enterprise compliance requirement)
// Assumes context.requestedAt is an ISO-8601 hour string "00"-"23"
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
principal.plan == "enterprise" &&
context.requestedHour >= "08" &&
context.requestedHour <= "18"
};
// Pattern 4: Verified author can call admin tools only on their own endpoints
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
principal.isVerified &&
resource.category == "admin" &&
resource.ownerId == principal.userId
};
Guarding absent attributes with has
Cedar's type system is strict: accessing an absent attribute causes a type error that makes the entire policy fail to evaluate (not just return false). The policy is silently skipped for that request, which in a deny-by-default system means the request is denied — and the errors array in IsAuthorized response will contain a PolicyEvaluationError.
Use the has operator to guard optional attributes before accessing them:
// WRONG: fails for users without a "plan" attribute (new users before billing webhook fires)
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
principal.plan == "free" // type error if principal.plan absent
};
// CORRECT: guard with has before access
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
principal has plan && principal.plan == "free"
};
// Alternative: use unless to deny rather than permit
// Grant access to everything EXCEPT where plan is set to a blocked value
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
unless {
principal has plan && principal.plan == "suspended"
};
A common bootstrap pattern: for brand-new users who haven't gone through the billing flow yet, create a temporary policy that grants read-only access to free tools without requiring a plan attribute. Once the billing webhook fires and sets the plan, the plan-based policies take over.
Forbid policies for revocation and compliance
Forbid policies fire before permit policies are checked. They are the correct mechanism for immediate revocation, suspension enforcement, and compliance blocks:
// Immediately block a compromised API key — fires before any permit
forbid (
principal == MCP::User::"compromised-key-789",
action,
resource
);
// Compliance block: no admin tools outside of business hours regardless of plan
forbid (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
resource.category == "admin" &&
!(context has requestedHour) ||
context.requestedHour < "06" ||
context.requestedHour > "22"
};
// Tenant quarantine: a tenant has a billing hold — block all their users from write tools
forbid (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
principal.tenantId == "tenant-on-hold" &&
resource.category == "write"
};
Because forbid beats permit with no override mechanism, be deliberate about the scope of each forbid. A broad forbid(principal, action, resource) blocks all requests from that principal — including dashboard reads. Scope the forbid to the relevant action or resource category if partial access should survive.
Action groups: authorizing sets of tools in one policy
Rather than writing separate policies for list_servers, get_server_status, get_probe_history, define an action group in the Cedar schema and write one policy that covers all read actions:
// In Cedar schema (JSON format):
"actions": {
"CallReadTool": {
"memberOf": [{ "id": "CallTool", "type": "Action" }],
"appliesTo": {
"principalTypes": ["User"],
"resourceTypes": ["Tool"]
}
},
"CallWriteTool": {
"memberOf": [{ "id": "CallTool", "type": "Action" }],
"appliesTo": {
"principalTypes": ["User"],
"resourceTypes": ["Tool"]
}
},
"CallAdminTool": {
"memberOf": [{ "id": "CallTool", "type": "Action" }],
"appliesTo": {
"principalTypes": ["User"],
"resourceTypes": ["Tool"]
}
}
}
// Policy using action group — covers all read tools in one permit statement
permit (
principal is MCP::User,
action in [MCP::Action::"CallReadTool"],
resource is MCP::Tool
)
when {
principal has plan && principal.plan != "suspended"
};
Action group membership is transitive: if CallReadTool is a member of CallTool, then checking action in [MCP::Action::"CallTool"] will match any action that is or inherits from CallTool — including CallReadTool, CallWriteTool, and CallAdminTool. This lets you write one policy for all tool calls and another policy for specific sub-groups.
Testing Cedar policies locally with the Cedar CLI
The Cedar CLI (open-source, github.com/cedar-policy/cedar) evaluates policies against test inputs without touching AWS. Use it in CI before uploading policies to AVP:
# Install: cargo install cedar-policy-cli
# Or use the pre-built binary from GitHub releases
# Test a policy
cedar authorize \
--policies policies/tool-access.cedar \
--schema schema.cedarschema \
--principal 'MCP::User::"user-free-123"' \
--action 'MCP::Action::"CallTool"' \
--resource 'MCP::Tool::"export_sla_report"' \
--entities entities.json \
--context '{"requestedHour": "14"}'
# Output: DENY (or ALLOW) + which policies matched
# Validate all policies against schema (catches attribute typos)
cedar validate --schema schema.cedarschema --policies policies/
// entities.json — test data matching your schema
[
{
"uid": { "type": "MCP::User", "id": "user-free-123" },
"attrs": {
"plan": "free",
"tenantId": "tenant-abc",
"isVerified": false
},
"parents": []
},
{
"uid": { "type": "MCP::Tool", "id": "export_sla_report" },
"attrs": {
"category": "admin",
"tier": "paid",
"ownerId": "user-team-456"
},
"parents": []
}
]
Add Cedar CLI validation as a CI step before any CreatePolicy or UpdatePolicy call. A policy that fails schema validation is stored in AVP (in STRICT mode it's rejected; in OFF mode it's stored but never evaluated), so catching it in CI is cheaper than debugging silent denials in production.
Policy coverage analysis: finding gaps
With deny-by-default, the risk is not over-permitting — it's leaving gaps where new tools or new users get silently denied. A simple coverage script enumerates all tool names and all plan values and probes AVP's BatchIsAuthorized endpoint to find denied pairs:
import { BatchIsAuthorizedCommand } from '@aws-sdk/client-verifiedpermissions';
const tools = ['list_servers', 'get_server_status', 'add_private_endpoint', 'export_sla_report'];
const plans = ['free', 'author', 'team', 'enterprise'];
const requests = tools.flatMap(toolName =>
plans.map(plan => ({
principal: { entityType: 'MCP::User', entityId: `coverage-test-${plan}` },
action: { actionType: 'MCP::Action', actionId: 'CallTool' },
resource: { entityType: 'MCP::Tool', entityId: toolName },
entities: {
entityList: [
{
identifier: { entityType: 'MCP::User', entityId: `coverage-test-${plan}` },
attributes: { plan: { string: plan }, tenantId: { string: 'test-tenant' }, isVerified: { boolean: false } },
},
],
},
}))
);
// BatchIsAuthorized supports up to 30 requests per call
const response = await avp.send(new BatchIsAuthorizedCommand({
policyStoreId: process.env.AVP_POLICY_STORE_ID!,
requests,
}));
response.results?.forEach((result, i) => {
const req = requests[i];
if (result.decision !== 'ALLOW') {
console.warn(`DENIED: plan=${plans[i % plans.length]} tool=${tools[Math.floor(i / plans.length)]}`);
}
});
How AliveMCP applies Cedar ABAC for tier enforcement
AliveMCP's authorization layer uses three static Cedar policies: a free-tier permit (tools with tier == "free"), a paid-tier permit (tools with tier in ["free","paid"] for Author/Team users), and an enterprise permit (all tools for Enterprise users). A fourth policy is a forbid on any user with plan == "suspended" regardless of tool. When a tool is promoted from free to paid (e.g., when historical probe data becomes a paid feature), a single update to the tool's tier attribute in the entity store blocks free-tier users automatically — no policy edit required. This is the core ABAC advantage: resource attribute changes propagate immediately without touching the policy document.