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 must still
remain valid JSON (at least for the time being due to automatic
rewriting by scripts/migrate-group).
  • Loading branch information
tsibley committed Jun 21, 2023
1 parent 6b8b122 commit 4d82959
Show file tree
Hide file tree
Showing 13 changed files with 349 additions and 3 deletions.
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"
}
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
}
4 changes: 2 additions & 2 deletions scripts/migrate-group.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ async function s3ListObjects({group}) {

async function updateServerPolicies({dryRun = true, oldBucket}) {
const policyFiles = [
"aws/iam/policy/NextstrainDotOrgServerInstance.json",
"aws/iam/policy/NextstrainDotOrgServerInstanceDev.json",
"aws/iam/policy/NextstrainDotOrgServerInstance.tftpl.json",
"aws/iam/policy/NextstrainDotOrgServerInstanceDev.tftpl.json",
];

const updatedFiles = await updatePolicyFiles({dryRun, policyFiles, oldBucket});
Expand Down
15 changes: 15 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,21 @@ app.routeAsync("/groups/:groupName/settings/overview")
.optionsAsync(optionsGroupSettings)
;

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";


const setGroup = (nameExtractor) => (req, res, next) => {
Expand Down Expand Up @@ -161,13 +163,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,
optionsGroupSettings,

getGroupLogo,
putGroupLogo,
deleteGroupLogo,

getGroupOverview,
putGroupOverview,
deleteGroupOverview,

listMembers,
listRoles,
listRoleMembers,

getRoleMember,
putRoleMember,
deleteRoleMember,
};
Loading

0 comments on commit 4d82959

Please sign in to comment.