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 Feb 15, 2023
1 parent 897aeb1 commit 4acf22d
Show file tree
Hide file tree
Showing 13 changed files with 347 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 @@ -70,6 +70,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 @@ -61,6 +61,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"
}
1 change: 1 addition & 0 deletions env/production/terraform.tf
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ module "iam" {
}

env = "production"
COGNITO_USER_POOL_ID = module.cognito.COGNITO_USER_POOL_ID
}

moved {
Expand Down
1 change: 1 addition & 0 deletions env/testing/terraform.tf
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ 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 @@ -333,6 +333,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 { 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 @@ -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,8 +1,11 @@
/* 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 { PRODUCTION } from './config.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 @@ -119,6 +122,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
Loading

0 comments on commit 4acf22d

Please sign in to comment.