Guide · AWS Verified Permissions · Multi-Tenancy
AWS Verified Permissions Policy Templates for Multi-Tenant MCP Servers
A policy template is a Cedar policy body with ?principal and ?resource as unbound placeholders. When you create a template-linked policy, you supply a concrete principal and resource and AVP instantiates the template on the fly. Three properties make templates essential for multi-tenant MCP servers: updating the template body immediately updates every linked policy — a single UpdatePolicyTemplate call cascades to all tenants; a template-linked policy cannot modify its own principal/resource bindings — the binding is fixed at creation, so even a compromised API key cannot re-scope its own access; and you can list all policies linked to a template with a single ListPolicies filter, giving you a complete view of which (principal, resource) pairs have been granted access under any given permission rule.
TL;DR
Create a PolicyTemplate with ?principal and ?resource placeholders. For each (user, tool) pair you want to grant, call CreatePolicy with policyType: TEMPLATE_LINKED. Updating the template propagates to all linked policies. Use ListPolicies(filter: { policyTemplateId }) to audit who has access. Delete a template to delete all linked policies (no cascade — you must delete linked policies individually first or AVP will reject the template delete).
Static policy vs template-linked policy
| Property | Static policy | Template-linked policy |
|---|---|---|
| Principal/resource binding | Hard-coded in policy body | Supplied at link time; template body uses ?principal, ?resource |
| Policy body update | Edit each policy individually | Edit template once; all linked policies update atomically |
| Scale | One policy per (user, tool) pair | One template; N linked policies sharing the same logic |
| Condition logic | Can reference both principal/resource attributes | Same — ?principal.plan evaluates at authorization time using the bound entity's attributes |
| Audit | No grouping mechanism | All policies sharing a template are filterable by policyTemplateId |
| Use when | One-off explicit grants; complex bespoke rules | Per-tenant provisioning; per-user resource grants; billing-event-triggered access |
Creating a policy template
The template body is Cedar syntax with ?principal and ?resource as literal placeholder tokens. These cannot appear in the when clause — they can only appear in the head (principal and resource scopes). Conditions in when use the entity attributes that AVP resolves at evaluation time from the bound entity.
import {
VerifiedPermissionsClient,
CreatePolicyTemplateCommand,
CreatePolicyCommand,
ListPoliciesCommand,
UpdatePolicyTemplateCommand,
} from '@aws-sdk/client-verifiedpermissions';
const avp = new VerifiedPermissionsClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
const policyStoreId = process.env.AVP_POLICY_STORE_ID!;
// Template: grant a specific user access to a specific private endpoint
// ?principal and ?resource are substituted at link time
const privateEndpointTemplate = await avp.send(new CreatePolicyTemplateCommand({
policyStoreId,
description: 'Grant a specific Author-tier user access to one private monitoring endpoint',
statement: `
permit (
principal == ?principal,
action == MCP::Action::"CallTool",
resource == ?resource
)
when {
?principal has plan &&
?principal.plan in ["author", "team", "enterprise"]
};
`,
}));
const templateId = privateEndpointTemplate.policyTemplateId!;
console.log('Template ID:', templateId);
// Store this in your database — you'll need it when provisioning per-user access
The ?principal has plan guard in the when clause prevents type errors for newly-created users who haven't been assigned a plan attribute yet. This is especially important with templates because the same template body is shared across all linked policies — a type error in one binding shouldn't silently deny a different binding that has the attribute.
Creating template-linked policies on user signup
When a user claims a private endpoint (e.g., upgrades to Author tier and adds their MCP server to AliveMCP's private monitoring), create a template-linked policy to grant that user access to the endpoint's tools:
interface ClaimEndpointEvent {
userId: string; // e.g., "user-alice-123"
endpointId: string; // e.g., "private-endpoint-myserver-456"
}
async function provisionEndpointAccess(event: ClaimEndpointEvent): Promise {
const policy = await avp.send(new CreatePolicyCommand({
policyStoreId,
definition: {
templateLinked: {
policyTemplateId: templateId,
principal: {
entityType: 'MCP::User',
entityId: event.userId,
},
resource: {
entityType: 'MCP::Tool',
entityId: `private-endpoint-${event.endpointId}`,
},
},
},
}));
// policy.policyId is the identifier for this specific (user, resource) link
// Store it in your database alongside the userId and endpointId
// so you can delete it on plan downgrade or endpoint deletion
console.log('Created policy:', policy.policyId);
return policy.policyId!;
}
// Triggered from billing webhook when a user upgrades and claims endpoint
await provisionEndpointAccess({
userId: 'user-alice-123',
endpointId: 'myserver-456',
});
Batch provisioning policies for a new tenant
When a new Team-tier tenant onboards, provision access to all their private endpoints at once. AVP doesn't have a native batch-create-policy endpoint, but you can fan out concurrent CreatePolicy calls:
interface TenantProvisioningEvent {
tenantId: string;
userIds: string[];
toolIds: string[]; // tools this tenant's private endpoints expose
}
async function provisionTenantAccess(event: TenantProvisioningEvent): Promise {
const CONCURRENCY = 5; // AVP default quota: 50 TPS; keep concurrency conservative
// Build all (user, tool) pairs
const pairs = event.userIds.flatMap(userId =>
event.toolIds.map(toolId => ({ userId, toolId }))
);
// Process in parallel batches
for (let i = 0; i < pairs.length; i += CONCURRENCY) {
const batch = pairs.slice(i, i + CONCURRENCY);
await Promise.all(batch.map(({ userId, toolId }) =>
avp.send(new CreatePolicyCommand({
policyStoreId,
definition: {
templateLinked: {
policyTemplateId: templateId,
principal: {
entityType: 'MCP::User',
entityId: userId,
},
resource: {
entityType: 'MCP::Tool',
entityId: toolId,
},
},
},
})).then(r => {
// Record policyId → (userId, toolId) in your database
return { policyId: r.policyId!, userId, toolId };
})
));
// Brief pause between batches to respect TPS quota
if (i + CONCURRENCY < pairs.length) await new Promise(r => setTimeout(r, 100));
}
}
await provisionTenantAccess({
tenantId: 'tenant-acme',
userIds: ['user-alice', 'user-bob', 'user-carol'],
toolIds: ['tool-list-endpoints', 'tool-get-status', 'tool-add-alert'],
});
Cascading policy updates via template edit
Templates shine when a permission rule changes across all tenants. For example, if you add a new when condition that gates access on ?principal.isVerified, you update the template once and every linked policy immediately reflects the new condition:
// Before: any Author/Team/Enterprise user can call the tool
// After: only verified users in those plans can call the tool
await avp.send(new UpdatePolicyTemplateCommand({
policyStoreId,
policyTemplateId: templateId,
description: 'Private endpoint access — verified users only',
statement: `
permit (
principal == ?principal,
action == MCP::Action::"CallTool",
resource == ?resource
)
when {
?principal has plan &&
?principal has isVerified &&
?principal.plan in ["author", "team", "enterprise"] &&
?principal.isVerified == true
};
`,
}));
// All N linked policies are now subject to the isVerified constraint
// No per-policy update required
This cascade is atomic — there is no window during which some linked policies use the old body and others use the new body. The update is applied to the template, and subsequent IsAuthorized evaluations use the current template body.
Auditing access with ListPolicies
Use ListPolicies with a filter to enumerate every (principal, resource) pair linked to a template — useful for compliance reports and access reviews:
import { ListPoliciesCommand, type PolicyItem } from '@aws-sdk/client-verifiedpermissions';
async function listTemplateLinkedPolicies(templateId: string): Promise {
const policies: PolicyItem[] = [];
let nextToken: string | undefined;
do {
const response = await avp.send(new ListPoliciesCommand({
policyStoreId,
nextToken,
filter: {
policyTemplateId: templateId,
policyType: 'TEMPLATE_LINKED',
},
}));
policies.push(...(response.policies ?? []));
nextToken = response.nextToken;
} while (nextToken);
return policies;
}
const linked = await listTemplateLinkedPolicies(templateId);
linked.forEach(p => {
const def = p.definition?.templateLinked;
console.log(
`policyId=${p.policyId}`,
`principal=${def?.principal?.entityId}`,
`resource=${def?.resource?.entityId}`,
`createdAt=${p.createdDate?.toISOString()}`,
);
});
Revoking access on plan downgrade or account deletion
When a user cancels their subscription or an admin revokes an endpoint claim, delete the template-linked policy. You must have stored the policyId at provisioning time — AVP has no "find policies by principal" query (you can only filter by template, effect, or resource type).
import { DeletePolicyCommand } from '@aws-sdk/client-verifiedpermissions';
async function revokeEndpointAccess(policyId: string): Promise {
await avp.send(new DeletePolicyCommand({
policyStoreId,
policyId,
}));
// The user's next tool call will be DENY (deny-by-default applies immediately)
// No cache to invalidate — AVP evaluates fresh on every IsAuthorized call
}
// On plan downgrade webhook:
// const policyIds = await db.query('SELECT policy_id FROM avp_policies WHERE user_id = ?', [userId]);
// await Promise.all(policyIds.map(id => revokeEndpointAccess(id)));
// await db.query('DELETE FROM avp_policies WHERE user_id = ?', [userId]);
Revocation takes effect on the next IsAuthorized call — there is no eventual consistency delay. If you issue a DeletePolicy at T=0, the next authorization call at T=1ms will return DENY for that (principal, resource) pair.
Template deletion: order matters
AVP will reject a DeletePolicyTemplate call if any template-linked policies still reference the template. You must delete all linked policies first:
import { DeletePolicyCommand, DeletePolicyTemplateCommand } from '@aws-sdk/client-verifiedpermissions';
async function deleteTemplateAndAllLinks(templateId: string): Promise {
// Step 1: list all linked policies
const linked = await listTemplateLinkedPolicies(templateId);
// Step 2: delete all linked policies
await Promise.all(
linked.map(p => avp.send(new DeletePolicyCommand({
policyStoreId,
policyId: p.policyId!,
})))
);
// Step 3: delete the template itself
await avp.send(new DeletePolicyTemplateCommand({
policyStoreId,
policyTemplateId: templateId,
}));
}
If you skip step 1–2 and call DeletePolicyTemplate directly, AVP returns a ConflictException listing the blocking policy IDs.
How AliveMCP uses policy templates for endpoint claims
AliveMCP has two templates in its production policy store: author-endpoint-access (permits an Author-tier user to call monitoring tools on their claimed endpoint) and team-endpoint-access (permits any user in a Team account to call monitoring and alerting tools on any of the team's private endpoints, where the team group membership is checked via ?principal in MCP::Group::"team-${tenantId}"). When a user upgrades from Free to Author, the billing webhook creates one linked policy per claimed endpoint. When they upgrade from Author to Team, the per-user policies are deleted and replaced with one team-scoped group policy. When they cancel, all linked policies are deleted in a single database-driven batch. The two templates together cover 100% of paid-tier access provisioning — no static policies needed for individual users.