Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[proposal] Add support for built in custom authorizers #356

Closed
jamesls opened this issue May 25, 2017 · 5 comments
Closed

[proposal] Add support for built in custom authorizers #356

jamesls opened this issue May 25, 2017 · 5 comments

Comments

@jamesls
Copy link
Member

jamesls commented May 25, 2017

This proposes adding support for writing custom authorizers
using chalice and seamlessly integrating them into a chalice app.

User API

I think we should continue on the decorator based approach
and add an authorizer decorator:

from chalice import Chalice

app = Chalice(app_name='foo')

@app.authorizer('MyAuthName')
def my_auth(auth_request):
    # More on the inputs/outputs later
    return Authorized(...)

From the code above:

  • The authorizer is declared with @app.authorizer()
  • You give each authorizer a name (or it defaults to the function name)
  • The authorizer accepts a single argument which is an auth request object.

Input: Auth Request

The auth request is based on the event dictionary lambda passes to
the authorizer:

class AuthRequest(object):
    def __init__(self, auth_type, token, method_arn):
        self.auth_type = auth_type
        self.token = token
        self.method_arn = method_arn

Output: Authorized/Unauthorized

The required response for API gateway is a policy indicating
what resources they are allowed to access. This is typically
arn:aws:execute-api:...:/some-path. You also provide
a principal id as well as optional context. This is all
handled in the Authorized class:

class Authorized(object):
    def __init__(self, paths, principal_id, context=None):
        pass

To simplify the interface, the user doesn't have to provide
the full ARNs and instead can just provide the resource paths:

return Authorized(paths=['/foo', '/bar'], principal_id=principal_id)

Connecting authorizers to view functions

To keep things simple, we can reuse the existing authorizer
option in the .route() call to indicate you want to use
a custom authorizer. Here's a full example:

@app.authorizer('MyAuth')
def my_auth(auth_request):
    token = auth_request.token
    try:
        decoded = jwt.decode(token, secret)
    except DecodeError:
        return Unauthorized(...)
    # Process the decoded jwt token...
    #
    return Authorized(paths=['/auth-request'],
                      context={}, principal_id)


@app.route('/auth-request', authorizer=my_auth)
def needs_auth():
    pass

Possible Implementation

The API gateway API for configuring a custom authorizer is that you
must provide a lambda function arn to use. I could route everything
through the existing chalice lambda handler (which only handles routes)
right now, but there's several downsides that I didn't like. The biggest
one is that you infer which type of request is being handled by inspecting the
set of keys in the event dict. If there's authType, token, methodArn, then
it's an auth request. Otherwise it's an API request. While that sounds fine
in theory, I'm weary of mixing auth requests with normal view requests.

So the proposal here is to create a separate lambda function for each
@app.authorizer. There's a few caveats though:

  • The exact same deployment package is used for the API lambda handler
    and the custom authorizer. The only difference is in the
    value of the handler passed to the create_function call.
    In the API handler case, it will be app.app. In the case
    of an authorizer, it will be app.<auth_function>. In the
    working example above this would be app.my_auth.
  • You use the same IAM role for the authorizer function that you do
    with the API handler.
  • This means that all custom authorizers must be declared in app.py.
    Your implementation could be elsewhere (separate package, somewhere in
    chalicelib, etc), but the registration and entry point of the authorizer
    needs to be in app.py.

As for the code itself, there's several places that need to change:

  • Updating the LambdaDeployer. It is still responsible for deploying
    lambda functions, but now it can possibly deploy multiple lambda
    functions.
  • Updating the deployed values from LambdaDeployer. The return
    type is still the same, but there will be a new key returned that's
    a mapping of all the lambda functions deployed.
  • The swagger generator needs to be updated to generate the correct
    snippet for a built in auth. The signature to the SwaggerGenerator
    needs to change. Right now you pass in a single lambda ARN, which
    we assume is the arn of the API handler. Now it should just accept
    the dict of deployed values, which will contain all the lambda ARNs
    we've deployed.
  • The API Gateway deployer needs to be updated to authorize the API
    gateway authorizer to be able to call this new lambda function. We
    had to add similar logic for the API lambda handler. We need the
    same thing for the API gateway authorizer.
  • Updates the SAM template generator to create these new lambda functions.

Tracking Deployment

To track what we've deployed, and to support more functions in the future,
I propose updating the deployed.json to add a new lambda_functions
key. The value is a dict of function name to function properties. The
function properties include the lambda arn, and the purpose of the function.
With this change the only value for purpose will be authorizer.
Example:

{
  "dev": {
    "region": "us-east-1",
    "api_handler_name": "customauth1-dev",
    "api_handler_arn": "arn:aws:lambda:us-east-1:123:function:customauth-dev",
    "rest_api_id": "95ne9sadl7",
    "lambda_functions": {
      "customauth-dev-jamesauth": {
        "arn": "arn:aws:lambda:us-east-1:123:function:customauth-dev-JamesAuth",
        "purpose": "authorizer"
      }
    },
    "chalice_version": "0.8.2",
    "api_gateway_stage": "dev",
    "backend": "api"
  }
}

With this model in place, we should be able to add more types of lambda functions
quickly (assuming the retrictions above still hold for these new function
types).

Configuring the Authorizers

The config is already hierarchical for most options. You can specify a config
value at either the stage level or the "global" level which applies to all
stages.

The proposal here is to introduce another level of granularity below stage that
is "per-lambda-function". With the exception of api_gateway_stage, all the
existing config options can now also be specified per-lambda-function:

  • manage_iam_role
  • iam_role_arn
  • autogen_policy
  • iam_policy_file
  • environment_variables
  • lambda_timeout
  • lambda_memory_size
  • tags

Now when deploying a lambda function, we'll check per-lambda-function,
per-stage, and the globally for config settings.

Example

{
  "version": "2.0",
  "app_name": "app",
  "stages": {
    "dev": {
      "autogen_policy": true,
      "api_gateway_stage": "dev"
    },
    "beta": {
      "autogen_policy": false,
      "lambda_functions": {
        "api_handler": {
          "iam_policy_file": "beta-app-policy-api.json"
        },
        "my_auth": {
          "iam_policy_file": "beta-app-policy-auth.json"
          "lambda_timeout": 10,
          "lambda_memory_size": 256
        }
      }
    },
    "prod": {
      "manage_iam_role": false,
      "iam_role_arn": "arn:aws:iam::...:role/prod-role"
    }
  }
}

The lambda_functions key specifies the per-lambda-function
granularity, and the keys are the function or resource names. The
api_handler name is a special built-in name for the main
lambda handler in chalice. Otherwise the names are either the
function name or the explicit name of the authorizer if one is given:

# The key name would be 'myauth'

@app.authorizer('myauth')
def somethingelse(auth_request):
    pass


# The key name would be 'jwtauth'

@app.authorizer()
def jwtauth(auth_request):
    pass

So for example, the my_auth function in the beta stage would have
these values from the per-lambda-function config:

  • iam_policy_file - "beta-app-policy-auth.json"
  • lambda_timeout - 10
  • lambda_memory_size - 256

And this value from the per-stage config:

  • autogen_policy - false

And nothing from the global config.

@stealthycoin
Copy link
Contributor

  • If we do not allow the memory, timeout values to be customized for authorizers, and we reuse the lambda_functions section later for lambda extra lambda functions, would there be the possibility that some of them would respect these extra values and some wouldn't, which wouldn't be entirely clear just by looking at the config file.

  • Shouldn't we also allow an input to control the ttl in apigateway of the returned policy?

@jamesls
Copy link
Member Author

jamesls commented May 25, 2017

I'm not sure how it would be possible that some lambda functions would respect the config values and some wouldn't. Presumably if there's a stage-level config value that applies to all lambda functions, they should all take that configuration value.

Good point about the TTL, that would likely be part of the authorizer definition, so what about:

@app.authorizer('MyAuthName', ttl=300)
def my_auth(auth_request):
    pass

@kyleknap
Copy link
Contributor

@jamesls the configuration spec looks good. I am assuming env vars and tags having the merging behavior when provided for a specific lambda function?

@jamesls
Copy link
Member Author

jamesls commented Jun 20, 2017

@kyleknap Yep, that's the plan.

@jamesls
Copy link
Member Author

jamesls commented Jun 22, 2017

Implemented via 4d1751d

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants