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.
  • Loading branch information
tsibley committed Oct 19, 2022
1 parent 677a0b1 commit dbb930d
Show file tree
Hide file tree
Showing 6 changed files with 321 additions and 0 deletions.
12 changes: 12 additions & 0 deletions aws/iam/policy/NextstrainDotOrgServerInstance.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
]
}
15 changes: 15 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(); });

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 { 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 <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 @@ -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,
};
87 changes: 87 additions & 0 deletions src/groups.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.<String>}>}
*/
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);
}
}


Expand Down
39 changes: 39 additions & 0 deletions src/utils/iterators.js
Original file line number Diff line number Diff line change
@@ -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);
}

0 comments on commit dbb930d

Please sign in to comment.