Skip to content

Commit 776022f

Browse files
committed
[provision group] Check changes against cognito
The provision-users command requires us to provide both a username and email for each user we want to add. A common use case is being given a (long) list of emails and wanting to check if a user already exists for each email, which is exactly what this script does. It goes further with the intention of giving confidence that we're not provisioning new users when we could be associating new roles/groups with already existing users.
1 parent da6cb7b commit 776022f

File tree

1 file changed

+258
-0
lines changed

1 file changed

+258
-0
lines changed
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
#!/usr/bin/env node
2+
import { ArgumentParser } from 'argparse';
3+
import {
4+
CognitoIdentityProviderClient,
5+
ListUsersCommand,
6+
AdminListGroupsForUserCommand
7+
} from '@aws-sdk/client-cognito-identity-provider';
8+
import fs from 'fs';
9+
import yaml from 'js-yaml';
10+
import process from 'process';
11+
import { Group } from '../src/groups.js';
12+
import { reportUnhandledRejectionsAtExit } from '../src/utils/scripts.js';
13+
/**
14+
* COGNITO_USER_POOL_ID will read from the eponymous env variable or look in a
15+
* config file. The config file is set via the env variable `CONFIG_FILE`, or
16+
* either env/production/config.json or env/testing/config.json depending on
17+
* your environment (NODE_ENV) */
18+
import { COGNITO_USER_POOL_ID, PRODUCTION } from '../src/config.js';
19+
20+
const DESCRIPTION = `
21+
A helper script to check provided emails against existing cognito users
22+
in the pool. The CLI interface is the same as 'provision-group' however
23+
no changes will be made (to cognito) by this script.
24+
25+
We attempt to check various scenarios (email matches, username matches) and print
26+
verbose output so that we can update existing users where possible.
27+
28+
P.S. Set 'CONFIG_FILE=env/production/config.json' to use the production cognito
29+
user pool; by default we'll use the pool defined in 'env/testing/config.json'
30+
or the env variable 'COGNITO_USER_POOL_ID' if set.
31+
`;
32+
33+
const REGION = COGNITO_USER_POOL_ID.split("_")[0];
34+
const cognito = new CognitoIdentityProviderClient({ region: REGION });
35+
36+
function parseArgs() {
37+
const argparser = new ArgumentParser({description: DESCRIPTION});
38+
argparser.addArgument("groupName", {metavar: "<name>", help: "Name of the Nextstrain Group"});
39+
argparser.addArgument("--members", {
40+
dest: "membersFile",
41+
metavar: "<file.yaml>",
42+
required: true,
43+
help: `
44+
A YAML file describing the members to add to the Group.
45+
The file must be an array of objects (i.e. dict/map/hash).
46+
Each object must contain an "email" key which is case-sensitive.
47+
The "username" key is optional for this script, but is required when you actually create provision the group membership.
48+
The "role" key is optional and may be set to "viewers", "editors", or "owners"; the default if not provided is "viewers".
49+
`
50+
});
51+
return argparser.parseArgs();
52+
}
53+
54+
55+
async function main({groupName, membersFile}) {
56+
// Canonicalizes name for us and ensures a data entry exists.
57+
const group = new Group(groupName);
58+
console.log(`[debug] Node environment: ${PRODUCTION ? 'production' : 'testing'}`)
59+
const members = readMembersFile(membersFile)
60+
const {usersByEmail, usersByUsername} = await cognitoUsers()
61+
await queryUsernames(group, members, usersByEmail, usersByUsername)
62+
}
63+
64+
65+
function readMembersFile(file) {
66+
const members = yaml.load(fs.readFileSync(file));
67+
68+
const validationErrors = !Array.isArray(members)
69+
? ["Not an array"]
70+
: members.filter(m => !m.email)
71+
.map(m => `Email missing for member ${JSON.stringify(m)}`)
72+
;
73+
74+
if (validationErrors.length) {
75+
const msg = validationErrors.map((err, i) => ` ${i+1}. ${err}`).join("\n");
76+
const s = validationErrors.length === 1 ? "" : "s";
77+
throw new Error(`Members file contains ${validationErrors.length} error${s}:\n${msg}`);
78+
}
79+
80+
return members;
81+
}
82+
83+
function getEmailFromUser(user) {
84+
return user.Attributes.filter(({Name}) => Name==='email')[0].Value;
85+
}
86+
87+
/**
88+
* Returns all the users in the pool. NOTE: we could make a cheaper request and query by email,
89+
* but in order to perform case-insensitive email matching we gather them all here.
90+
*/
91+
async function cognitoUsers() {
92+
// https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/cognito-identity-provider/command/ListUsersCommand/
93+
console.log(`[debug] Fetching existing membership data for cognito user pool "${COGNITO_USER_POOL_ID}"`)
94+
const params = {
95+
AttributesToGet: ['email'], // all users must have this else cmd fails
96+
UserPoolId: COGNITO_USER_POOL_ID,
97+
// Limit: 60, // default: 60
98+
}
99+
let Users, PaginationToken;
100+
try {
101+
({Users, PaginationToken} = await cognito.send(new ListUsersCommand(params)));
102+
while (PaginationToken) {
103+
const data = await cognito.send(new ListUsersCommand({...params, PaginationToken}));
104+
Users = [...Users, ...data.Users]
105+
PaginationToken = data.PaginationToken
106+
}
107+
} catch (e) {
108+
// Importing the actual error is difficult due to @aws-sdk/property-provider (where the error is defined)
109+
// being a nested dependency of `@aws-sdk/credential-provider-node`.
110+
if (e.name === 'CredentialsProviderError') {
111+
console.error(`FATAL ERROR: ${e.message}`)
112+
process.exit(2);
113+
}
114+
throw e;
115+
}
116+
// there may be duplicate emails (different users)
117+
const [usersByEmail, usersByUsername] = [{}, {}]
118+
for (const user of Users) {
119+
const email = getEmailFromUser(user);
120+
Object.hasOwn(usersByEmail, email) ? usersByEmail[email].push(user) : (usersByEmail[email]=[user])
121+
usersByUsername[user.Username] = user; // Username is unique (within a user pool)
122+
}
123+
124+
return {usersByEmail, usersByUsername};
125+
}
126+
127+
128+
/**
129+
* For a given nextstrain group and username, return the list of roles this username
130+
* already has for the (nextstrain) group. In theory a user should only have a single
131+
* role, but there's nothing enforcing this as far as I'm aware, so we return an array
132+
*/
133+
async function getUserRoles(group, username) {
134+
// roles are based upon group membership, which must be queried per-username
135+
const params = {
136+
Username: username,
137+
UserPoolId: COGNITO_USER_POOL_ID,
138+
}
139+
let cognitoGroups = [];
140+
try {
141+
const res = await cognito.send(new AdminListGroupsForUserCommand(params));
142+
cognitoGroups = res.Groups.map((g) => g.GroupName);
143+
if (!Array.isArray(cognitoGroups)) {
144+
throw new Error(`unexpected type for "Groups" data from the AdminListGroupsForUserCommand request for user ${username}`)
145+
}
146+
} catch (e) {
147+
console.error(`FATAL ERROR while fetching (cognito) groups for user ${username}`)
148+
console.error(e)
149+
process.exit(2)
150+
}
151+
152+
// for the specified nextstrain group, which cognito groups are relevant?
153+
// (each cognito group corresponds to a role)
154+
const rolesByCognitoGroup = new Map(
155+
Array.from(group.membershipRoles.entries())
156+
.map(([role, groupName]) => [groupName, role])
157+
);
158+
159+
const existingRoles = cognitoGroups
160+
.map((g) => rolesByCognitoGroup.has(g) ? rolesByCognitoGroup.get(g) : false)
161+
.filter(Boolean);
162+
163+
return existingRoles;
164+
}
165+
166+
167+
async function queryUsernames(group, members, usersByEmail, usersByUsername) {
168+
for (const member of members) {
169+
console.log()
170+
console.log(`Membership request for user "${member.username}" (${member.email}), role: ${member.role}`)
171+
172+
// ensure requested role is valid for this group
173+
if (!group.membershipRoles.has(member.role)) {
174+
console.error(`FATAL ERROR: Group ${group.name} doesn't support the role ${member.role}`)
175+
process.exit(2)
176+
}
177+
178+
if (Object.hasOwn(usersByEmail, member.email)) {
179+
// Pay the cost upfront to get all the roles currently set (in the context of the chosen
180+
// Nextstrain group) for all cognito users associated with this email address
181+
console.log(`\tThis email address is currently associated with ${usersByEmail[member.email].length} user(s)`)
182+
const userRoles = {};
183+
for (const user of usersByEmail[member.email]) {
184+
const roles = await getUserRoles(group, user.Username);
185+
userRoles[user.Username] = roles;
186+
console.log(`\t\t"${user.Username}" ` + (roles.length ? `roles: ${roles.join(", ")}` : '(no roles for this group)'))
187+
}
188+
const numAssociatedUsers = usersByEmail[member.email].length;
189+
190+
// TODO XXX - check case of emails...
191+
192+
if (member.username) {
193+
// Simplest case: the requested username is associated with the requested email
194+
if (Object.hasOwn(userRoles, member.username)) {
195+
if (userRoles[member.username].includes(member.role)) {
196+
console.log(`\tRequested email & username already has the role ${member.role}`);
197+
console.log(`\tNO ACTION NEEDED`);
198+
continue
199+
}
200+
console.log(`\tThis will change the role for user ${member.username} to ${member.role}`);
201+
if (numAssociatedUsers>1) {
202+
console.log(`\tACTION: Check above to see if there's another username which may be better suited, or if this role change is necessary and is desired`);
203+
}
204+
continue
205+
}
206+
207+
// situation: username exists and is associated with another email
208+
if (Object.hasOwn(usersByUsername, member.username)) {
209+
const usernameEmail = getEmailFromUser(usersByUsername[member.username]);
210+
const usernameRoles = await getUserRoles(group, member.username);
211+
console.log(`\tRequested username is associated with a different email address: "${usernameEmail}"`)
212+
console.log(`\tThat user currently has ` + (usernameRoles.length ? `roles ${usernameRoles.join(', ')}` : 'no roles associated with this group'))
213+
console.log(`\tACTION: Change the username or email for this member`)
214+
continue
215+
}
216+
217+
// situation: supplied username doesn't exist, will be created
218+
console.log(`\tThis will create a new user ${member.username} with role ${member.role}`)
219+
if (numAssociatedUsers>0) {
220+
console.log(`\tWARNING: The email already has associated users, consider using one of them instead.`)
221+
}
222+
continue
223+
}
224+
225+
// situation: email exists, no username provided
226+
console.log(`\tACTION: You didn't provide a username, so choose one of the ones which already exist for this email.`)
227+
continue
228+
}
229+
230+
// Situation: no email match
231+
console.log(`\tThis email address is currently not associated with any users`)
232+
233+
// situation: there is a username match under a different email
234+
if (member.username && Object.hasOwn(usersByUsername, member.username)) {
235+
const usernameEmail = getEmailFromUser(usersByUsername[member.username]);
236+
const usernameRoles = await getUserRoles(group, member.username);
237+
console.log(`\tRequested username is associated with a different email address: "${usernameEmail}"`)
238+
console.log(`\tThat user currently has ` + (usernameRoles.length ? `the role ${usernameRoles.join(', ')}` : 'no roles associated with this group'))
239+
console.log(`\tACTION: Change the email for this member, or choose a different username`);
240+
continue
241+
}
242+
243+
// situation: no email nor username match
244+
if (member.username) {
245+
console.log(`\tThis will create a new user with this email and username`);
246+
continue
247+
}
248+
console.log(`\tThis will create a new user but you will need to also define a username`);
249+
continue
250+
}
251+
}
252+
253+
reportUnhandledRejectionsAtExit();
254+
main(parseArgs())
255+
.catch((error) => {
256+
process.exitCode = 1;
257+
console.error(error)
258+
});

0 commit comments

Comments
 (0)