AWS Amplify lets you easily set up your back-end services so you can focus on building your application. Amplify provides you with Authentication, API's, Analytics and various other services. I'm a big fan of Amplify and so I'm documenting some of my experiences in order to help others as they explore and learn Amplify.

Today's task was to set up owner-based authorization in my GraphQL API (via AWS AppSync). Additionally, I want admins/superusers to also have access to the data (for administrative purposes).

Adding owner-based authorization

Setting up owner-based authorization is incredibly simple thanks to annotations that you add to your GraphQL schema. The AWS Amplify docs were a little dry on documentation of these attributes but a bit of googling and you can find more detailed information.

In order to add owner authorization, you simply use the @auth attribute with an allow rule:

type Profile  
  @model
  @auth(rules: [{allow: owner}])
{
  id: ID!
  name: String!
}

What this does behind the scenes is add some code to your resolvers:

Mutation.createProfile (request):

## START: Inject Ownership Information. **
#if( $util.isNullOrBlank($ctx.identity.username) )
$util.unauthorized()
#end
$util.qr($ctx.args.input.put("owner", $ctx.identity.username))
## END: Inject Ownership Information. **

On the read side of things you'll get code which verifies that the user is the owner:

Query.getProfile (response):

## START: Validate Ownership. **
#if( $ctx.result.owner == $ctx.identity.username )
#set($isAuthorized = true)
#end
## END: Validate Ownership. **

## START: Throw if Unauthorized. **
#if( !$isAuthorized )
$util.unauthorized()
#end
## END: Throw if Unauthorized. **

Thats all you need to have owner-based authorization. If there is no identity in the context an unauthorized error is returned.

Admin Group authorization

The above solution works great for owner-based auth but our application also needs to allow admins or superusers to see and modify that same data. Our @auth owner rule we defined above isn't going to cut it alone. So back to the documentation and it appears that we can use group authorization as well, though it doesn't explicitly say you can use both together. However, rules is an array so you would think its possible...

I tried the following:

type Profile  
  @model
  @auth(rules: [
    { allow: owner },
    { allow: groups, groups: ["admin"] }
  ])
{
  id: ID!
  name: String!
}

Notice the new allow rule: { allow: groups, groups: ["admin"] }

SPOILER this unfortunately does not work. When we try to create a new profile we receive an unauthorized error. Lets dig into the resolver and find out why:

## START: Inject Ownership Information. **
#if( $util.isNullOrBlank($ctx.identity.username) )
$util.unauthorized()
#end
$util.qr($ctx.args.input.put("owner", $ctx.identity.username))
## END: Inject Ownership Information. **
...

This first part looks fine... if there is no identity:

#if( $util.isNullOrBlank($ctx.identity.username)

it returns unauthorized:

$util.unauthorized()

In my application the user is already logged in (and therefore authorized) so this part of the resolver will continue without issue. Lets look at the rest of the resolver:

## START: Static Group Authorization. **
#set( $userGroups = $ctx.identity.claims.get("cognito:groups") )
#set( $allowedGroups = ["admin"] )
#set($isAuthorized = $util.defaultIfNull($isAuthorized, false))
#foreach( $userGroup in $userGroups )
  #foreach( $allowedGroup in $allowedGroups )
    #if( $allowedGroup == $userGroup )
      #set( $isAuthorized = true )
    #end
  #end
#end
## END: Static Group Authorization. **

## START: Throw if Unauthorized. **
#if( !$isAuthorized )
$util.unauthorized()
#end
## END: Throw if Unauthorized. **

The Static Group Authorization reads the groups from the identity then tries to match those groups to any of the allowed groups (admin) that we defined in our schema file.

Jumping further down to Throw if Unauthorized you can see that the determining factor for authorization is whether or not $isAuthorized is set.

Putting it all together we see that no other code sets $isAuthorized (even in the code that injects the owner). So if the user is not in the admin group $isAuthorized will be false and we'll get the unauthorized error. So it looks like adding multiple rules does not give us what we need. As of this writing there doesn't appear to be a way to support owner-OR-group based auth (as far as I've been able to tell).

Interestingly enough it does appear that the get/list resolvers support OR logic (notice it sets isAuthorized to true when checking owner OR it will set it to true if the user is in the allowed groups):

## START: Validate Ownership. **
#if( $ctx.result.owner == $ctx.identity.username )
#set($isAuthorized = true)
#end
## END: Validate Ownership. **

## START: Static Group Authorization. **
#set( $userGroups = $ctx.identity.claims.get("cognito:groups") )
#set( $allowedGroups = ["admin"] )
#set($isAuthorized = $util.defaultIfNull($isAuthorized, false))
#foreach( $userGroup in $userGroups )
  #foreach( $allowedGroup in $allowedGroups )
    #if( $allowedGroup == $userGroup )
      #set( $isAuthorized = true )
    #end
  #end
#end
## END: Static Group Authorization. **

## START: Throw if Unauthorized. **
#if( !$isAuthorized )
$util.unauthorized()
#end
## END: Throw if Unauthorized. **

There is an issue logged in the Amplify Github project. Hopefully, there will be a fix released soon. If you've found a way around this issue please share. I'll be keeping an eye on this issue and updating this post as well.

Happy Programming!