Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RESTful API for managing Groups members #581

Merged
merged 2 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion aws/iam/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ resource "aws_iam_policy" "server" {
name = local.server_policy_name
description = local.server_policy_description

policy = file("${path.module}/policy/${local.server_policy_name}.json")
policy = templatefile(
"${path.module}/policy/${local.server_policy_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 @@ -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}"
]
}
]
}
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}"
]
}
]
}
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 @@ -348,6 +348,21 @@ app.routeAsync("/groups/:groupName/settings/overview")
.optionsAsync(optionsGroup)
;

app.routeAsync("/groups/:groupName/settings/members")
.getAsync(endpoints.groups.listMembers);
Comment on lines +351 to +352
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently no way to invite new users. Need to sort out the desired interface/mechanism for this.

Is this something you want to start implementing in this PR with an endpoint like POST /groups/:groupName/settings/members/add? I assume it would behave similarly to some functions in the existing scripts/provision-group.


For naming reference, I went on a tangent and compiled a list of GitHub's endpoints for adding a new member to an organization. Their URLs are all over the place, so probably not a good source of inspiration:

  • https://github.com/orgs/<org>/people: members list
  • https://github.com/orgs/<org>/outside-collaborators: outside collaborators
  • https://github.com/orgs/<org>/pending_collaborators: pending collaborators
  • https://github.com/orgs/<org>/invitations/member_adder_add: clicking the Invite button sent a request to this URL
  • https://github.com/orgs/<org>/invitations/<username>/edit: direct link that works in browser
  • ...

Copy link
Member Author

@tsibley tsibley Aug 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like that. Not sure. Behaviour would be similar to internal provisioning, yep. I don't think I want to gate this PR on it, though, as I don't think it's strictly necessary to do so. We still have work to do for UI for these endpoints anyway (CLI and/or web).

Some questions around invitations:

  • Are we implementing a true invite → accept/decline invite → membership added flow from the start? Or just letting folks add anyone without an accept/decline step? Latter is much easier (esp. when we don't have our own database), but former is what we'll eventually want (although it may be quite a while till it's actually necessary to prevent abuse).

  • How do we customize emails from Cognito when a new person is invited for a group? We probably have to send our own separately if we're going to include details of the membership transaction.

  • Inviting is a little awkward with Cognito alone because it means the the invitee doesn't get a say in their username. Either we let the person inviting pick it (ha, nope!) or we force it internally (e.g. to match email). The latter is probably workable, but has privacy implications later, e.g. a username can be expected to be public but an email not so much.

and there are surely more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we almost certainly need to do the inviting new users part mostly outside of Cognito, and only touch Cognito when the user accepts (at which point they get a say in their user details and we add them to the right group+role).

Copy link
Contributor

@joverlee521 joverlee521 Aug 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to open up the user pool for self sign-up with admin confirmation? We can add an endpoint for the owner to just confirm new users and add them to the appropriate roles.

Then the user -> group communication can happen outside of our domain.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh, that sounds like a useful piece of the puzzle. I'd plain forgot about the self sign-up options! I'm not sure we'd even need the admin confirmation bit. Would need to think thru all the flows a bit more.

Earlier in the day, I'd idly noodled a bit on issuing stateless invitations (where all the state is encoded in a token we send the invited user, who hands it back to us (or not) when accepting/declining (or ignoring)). This ~works if invitations have a TTL and we're ok holding a small amount of state for a short time (TTL+1) when an invitation is rescinded. But what we can do with that approach is necessarily less than if we hold state on outstanding invitations, so it's not entirely appealing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggested the admin confirmation as a way for group owners to "vouch" for new users, but I guess adding them to their group has the same effect.

How much do we care about random people signing up for accounts? They won't have access to things unless they are added to a group, but do we have to worry about filling up Cognito quotas?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we care too much about random signups unaffiliated with a group. It's pointless, but I don't think we need to prevent it if it's easier to do a group invite flow without a confirmation step. We need not be concerned about user quotas in Cognito: the limit is 40 million. There are also operational quotas that apply to user signups, logins, etc. which would be impacted by additional not-necessary users, but I think we're unlikely to hit them either.


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)
victorlin marked this conversation as resolved.
Show resolved Hide resolved
;

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 @@
/**
tsibley marked this conversation as resolved.
Show resolved Hide resolved
* 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);
victorlin marked this conversation as resolved.
Show resolved Hide resolved

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
Loading