From dbb930d0e901a7e83f9377f4001d76e3889d7572 Mon Sep 17 00:00:00 2001 From: Thomas Sibley Date: Tue, 23 Aug 2022 15:26:58 -0700 Subject: [PATCH] Add RESTful API endpoints for managing Groups members Read-write access for group owners, read-only access for all other members of a group, and no access for non-members. --- .../NextstrainDotOrgServerInstance.json | 12 +++ src/app.js | 15 ++++ src/cognito.js | 85 ++++++++++++++++++ src/endpoints/groups.js | 83 ++++++++++++++++++ src/groups.js | 87 +++++++++++++++++++ src/utils/iterators.js | 39 +++++++++ 6 files changed, 321 insertions(+) create mode 100644 src/cognito.js create mode 100644 src/utils/iterators.js diff --git a/aws/iam/policy/NextstrainDotOrgServerInstance.json b/aws/iam/policy/NextstrainDotOrgServerInstance.json index 77bc264ba..7940c31cf 100644 --- a/aws/iam/policy/NextstrainDotOrgServerInstance.json +++ b/aws/iam/policy/NextstrainDotOrgServerInstance.json @@ -100,6 +100,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/us-east-1_Cg5rcTged" + ] } ] } diff --git a/src/app.js b/src/app.js index a725a4e00..311d4c4b6 100644 --- a/src/app.js +++ b/src/app.js @@ -332,6 +332,21 @@ app.routeAsync("/groups/:groupName/settings/overview") .deleteAsync(deleteGroupOverview) ; +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(); }); diff --git a/src/cognito.js b/src/cognito.js new file mode 100644 index 000000000..1c9cb4c48 --- /dev/null +++ b/src/cognito.js @@ -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 { NotFound } from "./httpErrors.js"; + + +const COGNITO_USER_POOL_ID = "us-east-1_Cg5rcTged"; +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 + */ +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; + } +} diff --git a/src/endpoints/groups.js b/src/endpoints/groups.js index 463122713..c2a029963 100644 --- a/src/endpoints/groups.js +++ b/src/endpoints/groups.js @@ -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) => { @@ -149,12 +151,93 @@ 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, + getGroupLogo, putGroupLogo, deleteGroupLogo, + getGroupOverview, putGroupOverview, deleteGroupOverview, + + listMembers, + listRoles, + listRoleMembers, + + getRoleMember, + putRoleMember, + deleteRoleMember, }; diff --git a/src/groups.js b/src/groups.js index 36ba2d25c..819ace47c 100644 --- a/src/groups.js +++ b/src/groups.js @@ -1,7 +1,10 @@ +/* eslint-disable no-await-in-loop */ import { strict as assert } from 'assert'; import * as authz from "./authz/index.js"; +import * as cognito from "./cognito.js"; import { NotFound } from './httpErrors.js'; import { GroupSource } from "./sources/index.js"; +import { markUserStaleBeforeNow } from "./user.js"; /* eslint-disable-next-line import/first, import/newline-after-import */ import { readFile } from 'fs/promises'; @@ -120,6 +123,90 @@ class Group { get authzTags() { return new Set([authz.tags.Type.Group]); } + + + /** + * List all group members. + * + * @returns {Array.<{username: String, roles: Set.}>} + */ + async members() { + const members = new Map(); + + for (const role of this.membershipRoles.keys()) { + for await (const {username} of this.membersWithRole(role)) { + const member = members.get(username) ?? { + username, + roles: new Set([]), + }; + + member.roles.add(role); + members.set(username, member); + } + } + + return [...members.values()]; + } + + /** + * List group members with a given role. + * + * @param {String} role - e.g. viewers, editors, or owners + * @yields {{username: String}} + */ + async* membersWithRole(role) { + const cognitoGroup = this.membershipRoles.get(role); + if (!cognitoGroup) throw new NotFound(`unknown role: ${role}`); + + for await (const user of cognito.listUsersInGroup(cognitoGroup)) { + yield { + username: user.Username, + }; + } + } + + /** + * Grant a role in this group to a user. + * + * Makes the user a group member if they weren't already. + * + * Any existing authn tokens the user has will be considered stale after + * calling this function so that the tokens are automatically refreshed to + * reflect their new role. + * + * @param {String} role - e.g. viewers, editors, or owners + * @param {String} username + * @throws {NotFound} on an unknown role + */ + async grantRole(role, username) { + const cognitoGroup = this.membershipRoles.get(role); + if (!cognitoGroup) throw new NotFound(`unknown role: ${role}`); + + await cognito.addUserToGroup(username, cognitoGroup); + await markUserStaleBeforeNow(username); + } + + /** + * Revoke a role in this group from a user. + * + * Removes the user from group membership if the removed role was their sole + * one. + * + * Any existing authn tokens the user has will be considered stale after + * calling this function so that the tokens are automatically refreshed to + * reflect their removed role. + * + * @param {String} role - e.g. viewers, editors, or owners + * @param {String} username + * @throws {NotFound} on an unknown role + */ + async revokeRole(role, username) { + const cognitoGroup = this.membershipRoles.get(role); + if (!cognitoGroup) throw new NotFound(`unknown role: ${role}`); + + await cognito.removeUserFromGroup(username, cognitoGroup); + await markUserStaleBeforeNow(username); + } } diff --git a/src/utils/iterators.js b/src/utils/iterators.js new file mode 100644 index 000000000..5dd382121 --- /dev/null +++ b/src/utils/iterators.js @@ -0,0 +1,39 @@ +/** + * Iterator utilities. + * + * @module utils.iterators + */ + + +/** + * Maps a function over any iterable, including async ones. + * + * If the iterable has a "map" property, it's called directly. + * + * @param {Object} it - [Iterable object]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterable_protocol}, e.g. an array, Set, Map, generator, async generator, etc. + * @param {Function} fn - Synchronous mapping function taking an element and returning a mapped value. + * @returns {Array} + */ +export async function map(it, fn) { + if (it.map) return it.map(x => fn(x)); + + const array = []; + for await (const x of it) { + /* Apply fn() as we go to avoid keeping all of the untransformed and + * potentially large elements in memory at once. + */ + array.push(fn(x)); + } + return array; +} + + +/** + * Consume an potentially-async iterable into an array. + * + * @param {Object} it - [Iterable object]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterable_protocol}, e.g. an array, Set, Map, generator, async generator, etc. + * @returns {Array} + */ +export async function slurp(it) { + return await map(it, x => x); +}