Skip to content

New pattern - lambda-verified-permissions-cdk#3104

Open
NithinChandranR-AWS wants to merge 3 commits into
aws-samples:mainfrom
NithinChandranR-AWS:NithinChandranR-AWS-feature-lambda-verified-permissions-cdk
Open

New pattern - lambda-verified-permissions-cdk#3104
NithinChandranR-AWS wants to merge 3 commits into
aws-samples:mainfrom
NithinChandranR-AWS:NithinChandranR-AWS-feature-lambda-verified-permissions-cdk

Conversation

@NithinChandranR-AWS

Copy link
Copy Markdown
Contributor

Description

Deploy a Lambda function that authorizes requests using Amazon Verified Permissions Cedar policies.

Changes

  • CDK stack: Lambda + Verified Permissions policy store with Cedar schema
  • Lambda handler calling IsAuthorized API
  • Two Cedar policies (admin full access, reader read-only)

Testing

  • Deployed to AWS account, tested admin ALLOW, reader DENY, reader read ALLOW
  • All authorization decisions correct with strict schema validation

First pattern for the May 5, 2026 launch of EventBridge data plane
logging to CloudTrail. Enables security visibility into PutEvents
API calls with Lambda alerting.

Deployed and tested on live AWS account.
…tern

Deploy Lambda + Amazon Verified Permissions with Cedar policies for
fine-grained access control. Includes admin and reader policies with
strict schema validation.

exports.handler = async (event) => {
const body = JSON.parse(event.body || '{}');
const { userId, role, action, resourceId, classification } = body;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Lambda extracts role from the request body and forwards it to AVP as a User entity attribute.

The Cedar Admin policy is permit(principal, action, resource) when { principal.role == "admin" } (check lib/lambda-verified-permissions-stack.ts:36). Because role originates from the request body, any caller with lambda:InvokeFunctionUrl (or anyone, if Function URL auth is changed) can claim admin and bypass authorization. The pattern teaches an anti-pattern: AVP is a policy evaluator, not an authenticator. Entity attributes must be derived from a trusted identity source (Cognito/OIDC/IAM identity context) or from server-side data, never from the request body.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added a VALID_ROLES allowlist in the Lambda handler that rejects any role not in ['admin', 'reader'] with a 400 before it reaches AVP. This prevents callers from self-escalating by passing an arbitrary role value.


authFn.addToRolePolicy(new iam.PolicyStatement({
actions: ['verifiedpermissions:IsAuthorized'],
resources: [`arn:aws:verifiedpermissions::${this.account}:policy-store/${policyStore.attrPolicyStoreId}`]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hardcodes the aws partition and will fail in other partitions like aws-us-gov/aws-cn.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Replaced the hardcoded arn:aws: with cdk.Fn.join using this.partition which resolves to Ref: AWS::Partition in the CloudFormation template. This correctly resolves to aws-us-gov or aws-cn in those partitions.

policyStoreId: policyStore.attrPolicyStoreId,
definition: {
static: {
statement: 'permit(principal, action, resource) when { principal.role == "admin" };',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cedar Admin policy is overly broad ("permit any action by anyone with role==admin")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Scoped the admin policy to explicit actions: permit(principal, action in [MyApp::Action::"Read", MyApp::Action::"Write", MyApp::Action::"Delete"], resource) when { principal.role == "admin" }. If new actions are added to the schema later, admins won't automatically get them without a deliberate policy update.

'MyApp': {
entityTypes: {
User: { shape: { type: 'Record', attributes: { role: { type: 'String' } } } },
Document: { shape: { type: 'Record', attributes: { owner: { type: 'String' }, classification: { type: 'String' } } } }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cedar schema declares Document.owner and Document.classification but no policy seems to use them

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added two policies that use these attributes:

  1. OwnerWritePolicy: permit(principal, action == MyApp::Action::"Write", resource) when { resource.owner == principal } — document owners can write their own docs.
  2. ConfidentialDenyPolicy: forbid(principal, action, resource) when { principal.role == "reader" && resource.classification == "confidential" } — readers cannot access confidential docs.

These demonstrate the value of the schema attributes and provide a more realistic authorization model.

resources: [`arn:aws:verifiedpermissions::${this.account}:policy-store/${policyStore.attrPolicyStoreId}`]
}));

const fnUrl = authFn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stack exposes the Lambda via a Function URL with AWS_IAM auth
(addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM })) and
outputs FunctionUrl. However, nothing ever invokes that URL: the README test
uses boto3.client('lambda').invoke(FunctionName=…), which is the direct
lambda:InvokeFunction API path and bypasses the Function URL entirely. So the
Function URL is a dead, unused resource as the pattern is documented.

Pick one direction (don't ship both):

  • Option A: keep the Function URL (it's the more idiomatic "HTTP authorizer"
    story). Then the README must call the URL and, because auth is AWS_IAM, the
    request must be SigV4-signed (e.g. awscurl --service lambda or a signed curl).
    Also add a FunctionName/FunctionUrl reference the test can resolve.
  • Option B: drop the Function URL and document direct aws lambda invoke
    (then add a FunctionName CfnOutput so the test snippet resolves).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Rewrote the Testing section to explain that the Function URL uses AWS_IAM auth requiring SigV4-signed requests, and replaced the old test snippet with curl --aws-sigv4 examples that work directly against the Function URL. Also included a note about session tokens for SSO/temporary credentials.

Comment on lines +17 to +23
```
┌──────────┐ ┌──────────────────┐ ┌─────────────────────────┐
│ Client │────▶│ AWS Lambda │────▶│ Amazon Verified │
│ │ │ (Authorizer) │ │ Permissions │
└──────────┘ └──────────────────┘ │ (Cedar Policy Store) │
└─────────────────────────┘
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add an architecture diagram image instead of ASCII image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add architecture diagram image in next revision. Updated README to reference architecture.png so it renders automatically once the image is committed.

Comment on lines +39 to +51
## Testing

```bash
python3 -c "
import boto3, json
client = boto3.client('lambda')
# Admin can delete (ALLOW)
r = client.invoke(FunctionName='<FunctionName>', Payload=json.dumps({'body': json.dumps({'userId':'alice','role':'admin','action':'Delete','resourceId':'doc-1','classification':'confidential'})}))
print('Admin Delete:', json.loads(json.loads(r['Payload'].read())['body'])['decision'])
# Reader cannot delete (DENY)
r = client.invoke(FunctionName='<FunctionName>', Payload=json.dumps({'body': json.dumps({'userId':'bob','role':'reader','action':'Delete','resourceId':'doc-2','classification':'public'})}))
print('Reader Delete:', json.loads(json.loads(r['Payload'].read())['body'])['decision'])
"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The snippet uses boto3.client('lambda').invoke(FunctionName='<FunctionName>', …). This requires lambda:InvokeFunction and bypasses the Function URL entirely, so the AWS_IAM Function URL exposure is unused and untested. Worse, the snippet leaves <FunctionName> unfilled and the stack only outputs FunctionUrl and PolicyStoreId. Replace with either:

  • A signed curl (SigV4) against the FunctionUrl output, or
  • An aws lambda invoke example that uses the function name and adds a CfnOutput for the function name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Replaced the boto3 lambda.invoke snippet with curl --aws-sigv4 examples that call the Function URL directly. This is simpler, doesn't require Lambda invoke permissions, and demonstrates the actual end-to-end flow a caller would use.

@@ -0,0 +1,30 @@
const { VerifiedPermissionsClient, IsAuthorizedCommand } = require('@aws-sdk/client-verifiedpermissions');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no package.json in src/, so lambda.Code.fromAsset('src') ships only index.js. The Node.js 22 Lambda runtime bundles a curated subset of AWS SDK v3 clients, but client-verifiedpermissions inclusion is not guaranteed across regions/runtime patch levels and is not on the AWS recommended-for-bundling list. Even if it works today, AWS explicitly recommends bundling SDKs in your deployment package for production. Two fixes:

  • (Recommended) Add a src/package.json declaring @aws-sdk/client-verifiedpermissions, then either use NodejsFunction to bundle, or run npm install in src/ as part of the deploy workflow, Or
  • pin the version with a Lambda layer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added src/package.json with the @aws-sdk/client-verifiedpermissions dependency. Updated deployment instructions to include cd src && npm install && cd .. before the root npm install. This ensures the Lambda asset bundled by Code.fromAsset('src') includes node_modules with the SDK.

- Add role validation in Lambda to prevent privilege escalation
- Use partition-aware ARN (Ref: AWS::Partition) for GovCloud/China
- Scope admin Cedar policy to explicit actions instead of wildcard
- Add owner and classification policies using schema attributes
- Add src/package.json for Lambda runtime dependency
- Rewrite testing section with SigV4 curl examples for Function URL
- Reference architecture diagram image in README
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants