AWS IAM SDK getting all policy documents for a specific role when using Cognito group roles

Viewed 214

I am using AWS Cognito User Pool groups to manage permissions for an API Gateway API. I believe this is a valid use for Groups as the documentation says so here: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-user-groups.html#using-groups-to-control-permission-with-amazon-api-gateway.

Unfortunately, the documentation is basically non-existent for this use case as far as I can tell (apart from that small paragraph). I am trying to figure out how this works with a custom API Gateway authoriser Lambda function. I have created a test role and assigned this to a test group in Cognito. The role has a single policy attached to it but in future the roles will have several policies.

Now, in my custom authoriser I am validating an access token etc already and that's all working fine. I am now trying to add this fine-grained access control in using the groups/roles/policies. I have installed the IAM SDK and have been digging around to see which calls I need to make. It seems that there is no easy way to get a role and all it's policies. The best I have come up with is the following:

public async Task<IEnumerable<string>> GetGroupPermissionsForUserAsync(Models.User user)
{
    if (!user.UserAttributes.TryGetValue(UserAttributeName.UserPoolId, out var userPoolId))
    {
        return null;
    }

    var groups = await GetUserGroups(user.Username, userPoolId);
    var groupRoleArn = groups.FirstOrDefault()?.RoleArn;
    var policies = new List<string>();
    if (!string.IsNullOrWhiteSpace(groupRoleArn))
    {
        var roleName = groupRoleArn.Substring(groupRoleArn.IndexOf('/') + 1);
        var rolePoliciesResponse = await _iamClient.ListAttachedRolePoliciesAsync(new ListAttachedRolePoliciesRequest { RoleName = roleName });
        
        foreach (var rolePolicy in rolePoliciesResponse.AttachedPolicies)
        {
            var policyVersionsResponse = await _iamClient.ListPolicyVersionsAsync(new ListPolicyVersionsRequest
            {
                PolicyArn = rolePolicy.PolicyArn
            });

            var latestPolicyVerson = policyVersionsResponse.Versions.OrderByDescending(x => x.CreateDate).LastOrDefault();
            var policyVersionResponse = await _iamClient.GetPolicyVersionAsync(new GetPolicyVersionRequest
            {
                PolicyArn = rolePolicy.PolicyArn,
                VersionId = latestPolicyVerson.VersionId
            });
            
            if (!string.IsNullOrWhiteSpace(policyVersionResponse?.PolicyVersion.Document))
            {
                policies.Add(HttpUtility.UrlDecode(policyVersionResponse.PolicyVersion.Document));
            }
        }
    }

    return policies;
}

private async Task<IEnumerable<GroupType>> GetUserGroups(string username, string userPoolId)
{
    string nextToken = null;
    var groups = new List<GroupType>();
    do
    {
        var response = await _cognitoClient.AdminListGroupsForUserAsync(new AdminListGroupsForUserRequest
        {
            Username = username,
            UserPoolId = userPoolId
        });

        groups.AddRange(response.Groups);

        nextToken = response.NextToken;
    } while (!string.IsNullOrWhiteSpace(nextToken));

    return groups.OrderBy(x => x.Precedence);
}

As you can see, I have to make a bunch of calls just to get the policies for a role.

  1. _cognitoClient.AdminListGroupsForUserAsync to get the user's groups from Cognito.
  2. _iamClient.ListAttachedRolePoliciesAsync to get the policies associated with the roles.
  3. _iamClient.ListPolicyVersionsAsync to get the versions for each policy.
  4. _iamClient.GetPolicyVersionAsync to get the individual policy version which finally has the policy document.

ListPolicyVersionsAsync returns a response with a document property but it's always null for some reason, hence the need for the extra GetPolicyVersionAsync call.

Not only does this all add latency (which is an issue on an authoriser function where every call to the API is going to be running through this code) but it leaves me with just a bunch of individual policies which I need to somehow de-dupe before returning to API Gateway.

It there really no easier/faster way to do this? Is there a way to get all this information with fewer calls and is there a way to flatten the policies in case their rules overlap?

1 Answers

There are so many different ways to set up Authorization with Cognito and API Gateway that it's difficult to say what will be best for you without knowing which AWS resources you are trying to access via your API Gateway methods. For instance, are your API Gateway methods integrating with lambda methods that access database info, or are they calling services directly?

If I understand correctly, you want to combine the permissions for the (potentially) multiple groups that a user may belong to in order to come up with a single set of permissions to use for that particular request. So, the user role may allow access to user data, and the admin role may further extend access to other admin endpoints?

Assuming that you have a pretty standard scenario where your API Gateway methods are integrating with a lambda method that is then accessing underlying AWS resources such as a database, then you could use Custom Authorizers as follows:

It simplifies things a great deal if Custom Authorizers just return the permissions needed to execute the requested API method:

{
  "principalId": "sub-from-ID-token",
  "policyDocument": {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": "execute-api:Invoke",
        "Effect": "Allow",
        "Resource": [
          "arn:aws:execute-api:eu-west-1:1234567890:qwerty:prod:GET/user/address"
        ]
      }
    ]
  }
}

In a standard scenario, a successfully authorized request would then execute the lambda that contains the api logic. This backing lambda would have an execution role which defines all of the permissions to access protected resources such as data in DynamoDB tables, etc. So anytime a different AWS resource is accessed by your backing lambda, you don't have to update the permissions for a number of group roles, but instead you manage those permissions at a single location in your system.

With this setup, you could have one fairly simple Custom Authorizer for each area of your api that needs protecting.

For instance, to protect your /user/* endpoints, you could have one custom authorizer that just checks to see if the ID token passed into the Authorizer contains the user group in its cognito:groups claim. If it does, then you return the permissions required to execute the requested api method (execute-api:Invoke). You may then have another custom authorizer that protects your /admin/* routes by checking to see if the admin group is in the ID token's cognito:groups claim, etc.

Caching of custom authorizer responses

Each custom authorizer has an optional TTL setting, which determines how long its response (for a particular JWT token) will be cached for. This helps to cut down on any lambda warm-up time, or time taken by additional callouts in the authorizer.

If an authorizer fronts a number of methods, e.g:

  • GET /user/address
  • POST /user/address
  • GET /user/mobile

then it is important to note that a successfully authorized response will be cached for all of these methods until the caching times out. Therefore the policy returned by the custom authorizer must return either a list of resources that define all of the methods that the user can execute, or use wildcards.

Example response for an authorizer that covers all /user/* routes

{
  "principalId": "sub-from-ID-token",
  "policyDocument": {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": "execute-api:Invoke",
        "Effect": "Allow",
        "Resource": [
          "arn:aws:execute-api:eu-west-1:1234567890:qwerty:prod:GET/user/address",
          "arn:aws:execute-api:eu-west-1:1234567890:qwerty:prod:POST/user/address",
          "arn:aws:execute-api:eu-west-1:1234567890:qwerty:prod:GET/user/mobile"
        ]
      }
    ]
  }
}

Example reponse (using wildcards) for an authorizer that covers all /user/* routes

{
  "principalId": "sub-from-ID-token",
  "policyDocument": {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": "execute-api:Invoke",
        "Effect": "Allow",
        "Resource": [
          "arn:aws:execute-api:eu-west-1:1234567890:qwerty:prod:*/user/*"
        ]
      }
    ]
  }
}
Related