TL;DR
With Typescript, we can define the permissions granted to our lambda functions in our serverless apps using types. This lets us type-check access to resources, raising at compile-time any errors caused by missing permissions.
// We define our app permissions as objects
const ProductsAccess = { Products: true } as const;
const MailerAccess = { Mailer: true } as const;
// And we restrict access to any resource by requiring those permissions
getProductStore = (_policy: typeof ProductsAccess) => new DynamoProductStore();
getMailService = (_policy: typeof MailerAccess) => new SESMailer();
// Then we define the type of policies required by our lambda as an argument
async function lambdaHandler(event: Event, policies: typeof ProductsAccess): Promise<Result> {
// And use those policies when requesting access to a resource
// so the compiler catches if we try to access a resource for which we don't have the required policy
const store = getProductStore(policies);
// Next line fails to compile since our policies do not include acess to the mail service!
const mailer = getMailService(policies);
}
And since we have defined our policies in code as part of each lambda handler, we can declare all parameters for each lambda in its handler file and use CDK to go through them and create the associated resources.
// src/api/get-product.ts
// We attach the lambda parameters to the handler function
export const handler = makeHandler(lambdaHandler, {
policies: ReadOnlyProducts,
httpMethod: 'GET',
logicalName: 'GetProductFunction',
resourcePath: 'products/{id}',
entry: __filename,
});
// bin/stack.ts
// And we iterate through all handlers and create the associated lambda
import { MyStack } from '../lib/my-stack';
import * as handlers from '../src/api';
const app = new cdk.App();
const stack = new MyStack(app, 'MyStack', { ... });
for (const handler of Object.values(handlers)) {
stack.makeFunction(handler.params);
}
// lib/stack.ts
// We build into the stack the logic to add each new lambda
export class MyStack extends Stack {
public makeFunction(params: HandlerParams) {
const fn = new NodejsFunction(this, params.logicalName, {
entry: path.relative(path.resolve(__dirname, '..'), params.entry),
...this.getFunctionSettings(),
});
this.grantRights(fn, params.policies);
this.api.root.resourceForPath(params.resourcePath)
.addMethod(params.httpMethod, new LambdaIntegration(fn));
}
}
This lets us define each Lambda in our application, along with its required permissions, in the same file where we declare the handler function, and leverage the type checker to catch any missing permissions.
So if you find this interesting, read on for the longer version, or check out this fork of the AWS serverless-typescript-demo
for a full code sample.
The problem with permissions errors
A common error when building serverless applications is missing a permission for accessing a resource in a lambda function. These errors are particularly annoying because they show up late in the development lifecycle, since unit or local tests do not uncover permissions errors: you depend on integration tests to check that your lambdas have all the rights needed to run properly. An error here requires a full redeploy of the stack and a retest, which takes valuable developer time.
Moving these errors to earlier in the development cycle yields a faster feedback loop. In this article, we'll explore how to catch these errors at compile time leveraging the Typescript type checker, and take it one step further by integrating our new typed permissions directly into our CDK stack definition.
Typing permissions
Let's start by defining our application permissions as Typescript types. For the sake of the example, let's say we have a Products
DynamoDB table in our application. We'll define two permissions, ReadProducts
and WriteProducts
, and use them to define a read-only and a full-access policy.
const ReadOnlyProducts = { ReadProducts: true } as const;
const ReadWriteProducts = { ...ReadOnlyProducts, WriteProducts: true } as const;
Note the as const
in the declarations. This ensures that the type of each policy requires a true
value for each property, and not any boolean.
We could have defined our policies as just an enumeration of permissions instead of using an object as we did above. But using objects like this plays nicely with the type system: a policy that extends another with additional permissions will also extend it in the eyes of the compiler.
function listProducts(policy: typeof ReadOnlyProducts) {
// ...
};
requiresReadProducts(ReadOnlyProducts); // Works
requiresReadProducts(ReadWriteProducts); // Works, since RW extends RO!
You can define whatever arbitrary permissions you need for your application. These can go about sending emails, managing users, or pushing notifications. And you can combine them freely into policies. We'll worry about converting them into actual IAM policies later.
// Example of a policy needed for running a UserManager
const UserManagerPolicy = {
ReadUsers: true,
WriteUsers: true,
SendEmails: true,
PushNotifications: true,
} as const;
Checking permissions when accessing resources
Once we have our sets of application permissions defined, we can now enforce them to request access to resources in our code.
Going back to the example from the previous section, we can abstract access to the Products
table behind a ProductStore
, with a DynamoDB-based default implementation.
export interface ProductStore {
getProduct: (id: string) => Promise<Product | undefined>;
getProducts: () => Promise<Product[]>;
putProduct: (product: Product) => Promise<void>;
deleteProduct: (id: string) => Promise<void>;
}
We can also restrict the interface above to getter methods, which we will use if the client has read-only permissions:
export type ReadOnlyProductStore = Pick<ProductStore, 'getProduct' | 'getProducts'>;
Now is where things get interesting: we can define a function that returns a read-only or full ProductStore
depending on the type of the policies supplied by the caller:
export function getProductStore
<Policy extends typeof ReadOnlyProducts>(_policy: Policy):
Policy extends typeof ReadWriteProducts
? ProductStore
: ReadOnlyProductStore {
return new DynamoProductStore();
}
Note that we don't even use the policy parameter in the body of the function, since actual permissions will be checked by the AWS policies we define. What we are doing here is asking the type system to check if the user has read-only or read-write permissions on the Products
table, and return the corresponding interface. On runtime, we just return the same implementation.
This achieves our goal of restricting access to resources based on the type of the policies we're working with, which raises any permission errors during compile-time.
// Works
getProductStore(ReadWriteProducts).putProduct(product);
// Property 'putProduct' does not exist on type 'ReadOnlyProductStore'
getProductStore(ReadOnlyProducts).putProduct(product);
// Argument of type '{ readonly ReadUsers: true; }' is not assignable to parameter of type '{ readonly ReadProducts: true; }'
getProductStore(ReadWriteUsers).putProduct(product);
Declaring permissions for each lambda
The most straightforward way to use this pattern is to declare, for each lambda, which set of permissions we will be granting it. This serves as a documentation attached to each function, which we can use to easily determine which policies we need to define in our infra-as-code.
async function lambdaHandler(event: APIGatewayProxyEvent) {
// We declare the policies for this lambda once
const policies = { ...ReadOnlyProducts, ...UserManagerPolicy };
// And use them whenever we need to instantiate a resource
const service = getService(policies);
}
But since we are already defining these policies in code, we can take this an extra step and use them to actually generate the policies in our infra-as-code template. As a matter of fact, we can extend this to generate the entire function template from a single source of truth.
Integrating with CDK
Our goal will be to write the policies for each lambda in a single place, and 1) have the type checker verify that we have the required policies for accessing all resources we need and 2) generate these policies in our infra-as-code template. Since we're already working with Typescript, we'll use CDK as our infra-as-code tool.
To do this, we'll attach the policies to the exported handler function, so we can query them when building our stack from CDK.
// src/api/get-product.ts
const policies = { ...ReadOnlyProducts };
const handler = (event: APIGatewayProxyEvent) => lambdaHandler(event, policies);
handler.params = { policies };
export { handler };
So when we define our stack using CDK constructs, we can import each handler and define its IAM permissions from the ones we exported.
// lib/stack.ts
import { handler as getProduct } from '../src/api/get-product';
// We define our resources in the stack constructor
const productsTable = new Table(...);
const api = new RestApi(...);
const getProductFunction = new NodejsFunction(
this,
"GetProductsFunction",
{ entry: "./src/api/get-products.ts", ...settings },
);
api.root.resourceForPath('products/{id}')
.addMethod('GET', new LambdaIntegration(getProductFunction));
// A helper function translates from our custom policies
// to the actual IAM permissions to be created
const grantRights(lambda, policies) => {
if (policies.ReadProducts) productsTable.grantReadData(lambda);
if (policies.WriteProducts) productsTable.grantWriteData(lambda);
};
// And we repeat for each lambda in the app
grantRights(getProductFunction, getProduct.params.policies);
But now we have split the definition for our lambda in two different places: policies are in the handler source file, and the rest of the settings are in the stack file.
What if consolidated them into a single source of truth in the handler?
Taking CDK integration one step further
Let's take the approach from the previous section one step further and inject all information relevant to the lambda when declaring its handler function:
// src/api/get-product.ts
const policies = { ...ReadOnlyProducts };
const handler = (event: APIGatewayProxyEvent) => lambdaHandler(event, policies);
handler.params = {
policies,
httpMethod: 'GET',
logicalName: 'GetProductFunction',
resourcePath: 'products/{id}',
entry: __filename,
};
export { handler };
Now we can add a function to our stack that accepts this set of parameters and creates a new lambda, connects it to the API gateway, and grants it the set of permissions defined in the policies:
// lib/stack.ts
class MyStack extends Stack {
public makeFunction(params: HandlerParams) {
const fn = new NodejsFunction(this, params.logicalName, {
entry: path.relative(path.resolve(__dirname, '..'), params.entry),
...this.getFunctionSettings(),
});
this.grantRights(fn, params.policies);
this.api.root.resourceForPath(params.resourcePath)
.addMethod(params.httpMethod, new LambdaIntegration(fn));
}
}
And we just invoke it for each handler in our application when constructing the stack:
// bin/stack.ts
import * as handlers from '../src/api';
const app = new cdk.App();
const stack = new MyStack(app, 'MyStack', { ... });
for (const handler of Object.values(handlers)) {
stack.makeFunction(handler.params);
}
This way, we have the definitions of the main resources of the application in our lib/stack.ts
file, while each function is declared in its own individual file, along with its associated permissions - which get type checked when requesting access to any resource!
Full example
You can check a full code sample of the pattern above in spalladino/serverless-typescript-demo
, which is a fork of the aws-samples/serverless-typescript-demo
that showcases a products CRUD built using Typescript and CDK.
Top comments (0)