Skip to content

Commit

Permalink
Add RESTful API endpoints for managing Groups members
Browse files Browse the repository at this point in the history
Read-write access for group owners, read-only access for all other
members of a group, and no access for non-members.

Expands the server's AWS IAM policy to allow access to a few required
Cognito user pool API actions.  The policy files are .tftpl.json instead
of .json.tftpl to indicate that although templated they still remain
valid JSON (at least for the time being).
  • Loading branch information
tsibley committed Sep 14, 2023
1 parent 3e55d17 commit ad853a2
Show file tree
Hide file tree
Showing 13 changed files with 357 additions and 1 deletion.
6 changes: 5 additions & 1 deletion aws/iam/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ resource "aws_iam_policy" "server" {
name = local.server_policy_name
description = local.server_policy_description

policy = file("${path.module}/policy/${local.server_policy_file_name}.json")
policy = templatefile(
"${path.module}/policy/${local.server_policy_file_name}.tftpl.json", {
COGNITO_USER_POOL_ID = var.COGNITO_USER_POOL_ID
}
)
}

moved {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@
"Resource": [
"arn:aws:s3:::nextstrain-groups/*"
]
},
{
"Sid": "CognitoUserPoolActions",
"Effect": "Allow",
"Action": [
"cognito-idp:AdminAddUserToGroup",
"cognito-idp:AdminRemoveUserFromGroup",
"cognito-idp:ListUsersInGroup"
],
"Resource": [
"arn:aws:cognito-idp:us-east-1:827581582529:userpool/${COGNITO_USER_POOL_ID}"
]
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@
"arn:aws:s3:::nextstrain-groups/test/*",
"arn:aws:s3:::nextstrain-groups/test-private/*"
]
},
{
"Sid": "CognitoUserPoolActions",
"Effect": "Allow",
"Action": [
"cognito-idp:AdminAddUserToGroup",
"cognito-idp:AdminRemoveUserFromGroup",
"cognito-idp:ListUsersInGroup"
],
"Resource": [
"arn:aws:cognito-idp:us-east-1:827581582529:userpool/${COGNITO_USER_POOL_ID}"
]
}
]
}
1 change: 1 addition & 0 deletions aws/iam/variable-COGNITO_USER_POOL_ID.tf
4 changes: 4 additions & 0 deletions aws/variable-COGNITO_USER_POOL_ID.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
variable "COGNITO_USER_POOL_ID" {
type = string
description = "Id of the Cognito user pool for this environment"
}
10 changes: 10 additions & 0 deletions docs/api-restful.rst
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,16 @@ The following group settings endpoints exist::

{GET, HEAD, PUT, DELETE, OPTIONS} /groups/{name}/settings/overview

The following group membership endpoints exist::

{GET, HEAD, OPTIONS} /groups/{name}/settings/members

{GET, HEAD, OPTIONS} /groups/{name}/settings/roles

{GET, HEAD, OPTIONS} /groups/{name}/settings/roles/{role}/members

{GET, HEAD, PUT, DELETE, OPTIONS} /groups/{name}/settings/roles/{role}/members/{username}

.. _motivation:

Motivation
Expand Down
2 changes: 2 additions & 0 deletions env/production/terraform.tf
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ module "iam" {
}

env = "production"

COGNITO_USER_POOL_ID = module.cognito.COGNITO_USER_POOL_ID
}

moved {
Expand Down
2 changes: 2 additions & 0 deletions env/testing/terraform.tf
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,6 @@ module "iam" {
}

env = "testing"

COGNITO_USER_POOL_ID = module.cognito.COGNITO_USER_POOL_ID
}
15 changes: 15 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,21 @@ app.routeAsync("/groups/:groupName/settings/overview")
.optionsAsync(optionsGroup)
;

app.routeAsync("/groups/:groupName/settings/members")
.getAsync(endpoints.groups.listMembers);

app.routeAsync("/groups/:groupName/settings/roles")
.getAsync(endpoints.groups.listRoles);

app.routeAsync("/groups/:groupName/settings/roles/:roleName/members")
.getAsync(endpoints.groups.listRoleMembers);

app.routeAsync("/groups/:groupName/settings/roles/:roleName/members/:username")
.getAsync(endpoints.groups.getRoleMember)
.putAsync(endpoints.groups.putRoleMember)
.deleteAsync(endpoints.groups.deleteRoleMember)
;

app.route("/groups/:groupName/settings/*")
.all(() => { throw new NotFound(); });

Expand Down
85 changes: 85 additions & 0 deletions src/cognito.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Cognito user pool (IdP) management.
*
* @module cognito
*/
/* eslint-disable no-await-in-loop */
import {
CognitoIdentityProviderClient,
AdminAddUserToGroupCommand,
AdminRemoveUserFromGroupCommand,
paginateListUsersInGroup,
UserNotFoundException,
} from "@aws-sdk/client-cognito-identity-provider";

import { COGNITO_USER_POOL_ID } from "./config.js";
import { NotFound } from "./httpErrors.js";


const REGION = COGNITO_USER_POOL_ID.split("_")[0];

const cognito = new CognitoIdentityProviderClient({ region: REGION });


/**
* Retrieve AWS Cognito users in a Cognito group.
*
* @param {string} name - Name of the AWS Cognito group
* @yields {object} user, see <https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-cognito-identity-provider/interfaces/usertype.html>
*/
export async function* listUsersInGroup(name) {
const paginator = paginateListUsersInGroup({client: cognito}, {
UserPoolId: COGNITO_USER_POOL_ID,
GroupName: name,
});

for await (const page of paginator) {
yield* page.Users;
}
}


/**
* Add an AWS Cognito user to a Cognito group.
*
* @param {string} username
* @param {string} group - Name of the AWS Cognito group
* @throws {NotFound} if username is unknown
*/
export async function addUserToGroup(username, group) {
try {
await cognito.send(new AdminAddUserToGroupCommand({
UserPoolId: COGNITO_USER_POOL_ID,
Username: username,
GroupName: group,
}));
} catch (err) {
if (err instanceof UserNotFoundException) {
throw new NotFound(`unknown user: ${username}`);
}
throw err;
}
}


/**
* Remove an AWS Cognito user from a Cognito group.
*
* @param {string} username
* @param {string} group - Name of the AWS Cognito group
* @throws {NotFound} if username is unknown
*/
export async function removeUserFromGroup(username, group) {
try {
await cognito.send(new AdminRemoveUserFromGroupCommand({
UserPoolId: COGNITO_USER_POOL_ID,
Username: username,
GroupName: group,
}));
} catch (err) {
if (err instanceof UserNotFoundException) {
throw new NotFound(`unknown user: ${username}`);
}
throw err;
}
}
83 changes: 83 additions & 0 deletions src/endpoints/groups.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as authz from "../authz/index.js";
import { NotFound } from "../httpErrors.js";
import { Group } from "../groups.js";
import {contentTypesProvided, contentTypesConsumed} from "../negotiate.js";
import {deleteByUrls, proxyFromUpstream, proxyToUpstream} from "../upstream.js";
import { slurp } from "../utils/iterators.js";
import * as options from "./options.js";


Expand Down Expand Up @@ -152,13 +154,94 @@ async function receiveGroupLogo(req, res) {
}


/* Members and roles
*/
const listMembers = async (req, res) => {
const group = req.context.group;

authz.assertAuthorized(req.user, authz.actions.Read, group);

return res.json(await group.members());
};


const listRoles = (req, res) => {
const group = req.context.group;

authz.assertAuthorized(req.user, authz.actions.Read, group);

const roles = [...group.membershipRoles.keys()];
return res.json(roles.map(name => ({name})));
};


const listRoleMembers = async (req, res) => {
const group = req.context.group;
const {roleName} = req.params;

authz.assertAuthorized(req.user, authz.actions.Read, group);

return res.json(await slurp(group.membersWithRole(roleName)));
};


const getRoleMember = async (req, res) => {
const group = req.context.group;
const {roleName, username} = req.params;

authz.assertAuthorized(req.user, authz.actions.Read, group);

for await (const member of group.membersWithRole(roleName)) {
if (member.username === username) {
return res.status(204).end();
}
}

throw new NotFound(`user ${username} does not have role ${roleName} in group ${group.name}`);
};


const putRoleMember = async (req, res) => {
const group = req.context.group;
const {roleName, username} = req.params;

authz.assertAuthorized(req.user, authz.actions.Write, group);

await group.grantRole(roleName, username);

return res.status(204).end();
};


const deleteRoleMember = async (req, res) => {
const group = req.context.group;
const {roleName, username} = req.params;

authz.assertAuthorized(req.user, authz.actions.Write, group);

await group.revokeRole(roleName, username);

return res.status(204).end();
};


export {
setGroup,
optionsGroup,

getGroupLogo,
putGroupLogo,
deleteGroupLogo,

getGroupOverview,
putGroupOverview,
deleteGroupOverview,

listMembers,
listRoles,
listRoleMembers,

getRoleMember,
putRoleMember,
deleteRoleMember,
};
Loading

0 comments on commit ad853a2

Please sign in to comment.