Serverless Multi-Tenant Python Apps Made Easy with Chalice and PropelAuth
Serverless applications can feel a little magical. You write some small amount of code like this
app = Chalice(app_name='helloworld')
@app.route("/")
def index():
return {"hello": "world"}
deploy it, and then you have an API route that you can hit. You don’t have to worry about managing any infrastructure or scaling things up and demanding increases.
Serverless does come with some drawbacks though. If you’ve ever tried to manage a Lambda function manually, you know that the developer experience leaves a lot to be desired. This is especially true for python projects that have dependencies that need to be bundled.
This is where Chalice comes in. Chalice lets you quickly deploy serverless apps in Python and manages the process of setting up AWS Lambda functions with all their dependencies.
We’ll use Chalice along with PropelAuth to quickly set up the backend for a multi-tenant application. PropelAuth will allow us to quickly add authenticated routes like this:
@app.route('/{org_id}/admin_only')
@require_admin_in_org
def index(user, org_member_info, org_id):
return {'user_id': user.user_id,
'org_id': org_id,
'org_name': org_member_info.org_name}
Creating a serverless API route
We’ll start by creating and deploying a single route. And for that, we’ll create a virtual environment and install Chalice:
$ python3 -m venv venv
$ source ./venv/bin/activate
$ pip install chalice
Chalice manages resources within your AWS account, so you’ll need to also set up the AWS CLI.
$ aws configure
Finally, we can create our project:
$ chalice new-project example
This creates an example
directory with an [app.py](<http://app.py>)
that contains the following:
from chalice import Chalice
app = Chalice(app_name='example')
@app.route('/')
def index():
return {'hello': 'world'}
If we cd
into the directory and run chalice deploy
, we get:
$ chalice deploy
Creating deployment package.
Updating policy for IAM role: example-dev
Updating lambda function: example-dev
Updating rest API
Resources deployed:
- Lambda ARN: {ARN}
- Rest API URL: https://{uniquestring}.execute-api.us-west-2.amazonaws.com/api/
And our endpoint is live! You can test it with curl:
$ curl {Rest API URL from above}
{"hello":"world"}
Under the hood, you now have a lambda function in your AWS account that is run when someone hits that endpoint. You can actually see the cold start problem very clearly here because your first request to this endpoint will be a bit slower than every subsequent request.
Adding authentication to our serverless routes
We have an endpoint now that anyone can hit and get a response. In most real-world applications, however, we will have routes that only logged in users can access. Let’s set up a route that just returns the current user’s ID:
@app.route('/whoami')
def whoami():
return {'user_id': 'uhhhhhhh'}
But…we don’t have a concept of users yet. For this, we can use PropelAuth, which provides us with everything we need for login, signup, and user management. We’ll see in the next section that it also has multi-tenancy built in by default.
After following the getting started guide and configuring our authentication (e.g. adding passwordless/SSO options or collecting more metadata on signup), we’ll have a signup page that looks like this:
And we can use this to test our backend before we’ve built out our frontend, as we already have all the UIs we need to signup as a test user.
On the backend, we can now use propelauth-py to protect our API routes. Our backend expects an access token to be passed in via the Authorization header, in the format Bearer {TOKEN}
.
$ pip install propelauth-py
propelauth-py’s validate_access_token_and_get_user
function takes in the Authorization header, parses out the token, and validates the access token. Importantly, this validation can be done quickly and without making any external requests, which is ideal for serverless environments.
Let’s hook it up directly in our route:
from propelauth_py import init_base_auth, TokenVerificationMetadata, UnauthorizedException
app = Chalice(app_name='example')
# You should replace these with your values from your dashboard
auth = init_base_auth('AUTH_URL', 'API_KEY',
token_verification_metadata=TokenVerificationMetadata(
verifier_key='''YOUR_KEY''',
issuer='AUTH_URL'
))
@app.route('/whoami')
def whoami():
try:
auth_header = app.current_request.headers.get("authorization")
user = auth.validate_access_token_and_get_user(auth_header)
return {'user_id': user.user_id}
except UnauthorizedException:
# UnauthorizedError is Chalice's exception which will return a 401
raise UnauthorizedError
Re-deploy your code:
$ chalice deploy
And now when we curl the endpoint, we’d expect a 401 since we aren’t providing an access token:
$ curl {URL}/whoami
{"Code":"InternalServerError","Message":"An internal server error occurred."}
But we actually get an internal server error? If you check your lambda logs in AWS, you’ll see that our lambda function doesn’t know what propelauth-py is. Chalice isn’t using our virtual environment to understand your project’s dependencies, it’s using the requirements.txt
file. If we update that file with:
propelauth-py==2.0.4
and redeploy, we get our 401:
$ curl {URL}/whoami
{"Code":"UnauthorizedError","Message":""}
We should also verify that a valid access token returns a 200 with our user id. Take the AUTH_URL
from your PropelAuth dashboard and visit {AUTH_URL}/api/v1/refresh_token
, which will return both an access token and metadata about the user. We’ll pass this along in the Authorization header:
$ curl -H "Authorization: Bearer eyJ..." {URL}/whoami
{"user_id":"c7fb5888-8ff2-4ad6-aa10-7775911ecd41"}
Cleaning up our authentication with python decorators
Adding that much code to each route isn’t appealing. What we’d prefer to do is something like:
@app.route('/whoami')
@require_user
def whoami(user):
return {'user_id': user.user_id}
And we can do that by creating a decorator to wrap our function. You can read more about decorators here and here is what it looks like for us:
def require_user(func):
def wrapper(*args, **kwargs):
try:
auth_header = app.current_request.headers.get("authorization")
user = auth.validate_access_token_and_get_user(auth_header)
except UnauthorizedException:
raise UnauthorizedError
# All functions expect the user as the first argument
return func(user, *args, **kwargs)
return wrapper
Adding Multi-tenancy to our API routes
In B2B products, your users aren’t using your product individually. They’ll want to invite their coworkers and use your product as a team. This is sometimes called an organization, a tenant, shared account, a team, and many more. PropelAuth provides an abstraction on top of these tenants and provides self-service UIs so our users can invite others, set up enterprise SSO connections, manage RBAC, and more.
propelauth-py also has a function for this, let’s see it in action:
@app.route('/{org_id}/admin_only')
def admin_only(org_id):
try:
auth_header = app.current_request.headers.get("authorization")
user_and_org_member_info = auth.validate_access_token_and_get_user_with_org(
auth_header, org_id, minimum_required_role=UserRole.Admin
)
return {'user_id': user_and_org_member_info.user.user_id,
'org_name': user_and_org_member_info.org_member_info.org_name}
except UnauthorizedException:
raise UnauthorizedError
except ForbiddenException:
# We return a 403 for valid users that do not have access to the specified organization
raise ForbiddenError
In this route, we take in an organization / tenant as a path parameter and use validate_access_token_and_get_user_with_org
to check if the current user is a member of that organization. Additionally, we verify that they are at least an Admin within the organization, otherwise we return a 403.
We can also clean this up with a decorator:
def require_admin_in_org(func):
def wrapper(*args, **kwargs):
try:
auth_header = app.current_request.headers.get("authorization")
org_id = app.current_request.uri_params["org_id"]
user_and_org_member_info = auth.validate_access_token_and_get_user_with_org(
auth_header, org_id, minimum_required_role=UserRole.Admin
)
return func(user_and_org_member_info.user, user_and_org_member_info.org_member_info, *args, **kwargs)
except UnauthorizedException:
raise UnauthorizedError
except ForbiddenException as e:
raise ForbiddenError
return wrapper
Allowing us to write a very simple route:
@app.route('/{org_id}/admin_only')
@require_admin_in_org
def index(user, org_member_info, org_id):
return {'user_id': user.user_id,
'org_name': org_member_info.org_name}
The beautify of this route is that in ~5 lines of code we have an API endpoint that is serverless, easy to maintain, and works with all types of organizations—from small companies to large enterprises using SAML connections to their IDPs.
What’s next?
Curl’ing our API is fine but will get old quickly. One of our next tasks is to set up the frontend and use that to pass along the access token.
Similarly, we’ll want our backend to do more than just echo back who we are. We’ll likely want to include a database that works well in a serverless environment, like Fauna.
Also, Chalice doesn’t just work with APIs, you can set it up to run periodic tasks like:
@app.schedule(Rate(30, unit=Rate.MINUTES))
def periodic_task(event):
# TODO: Calculate and report usage metrics
return {"hello": "world"}
Summary
Serverless functions are very powerful but historically have a poor developer experience. Chalice allows us to quickly deploy API routes with very little code. When combined with PropelAuth—we can have a full enterprise-ready multi-tenant application quickly and easily.