Skip to content

Commit 5ba52eb

Browse files
authored
Merge pull request #2050 from Kobzol/impersonation-check
Do not notify users when a non-sensitive command is executed on their behalf
2 parents 0829df5 + 488642c commit 5ba52eb

File tree

2 files changed

+140
-156
lines changed

2 files changed

+140
-156
lines changed

src/github.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,16 +267,17 @@ impl User {
267267
}
268268

269269
// Returns the ID of the given user, if the user is in the `all` team.
270-
pub async fn get_id_for_username<'a>(
271-
client: &'a GithubClient,
270+
pub async fn get_id_for_username(
271+
client: &GithubClient,
272272
login: &str,
273273
) -> anyhow::Result<Option<u64>> {
274274
let permission = crate::team_data::teams(client).await?;
275275
let map = permission.teams;
276+
let login = login.to_lowercase();
276277
Ok(map["all"]
277278
.members
278279
.iter()
279-
.find(|g| g.github == login)
280+
.find(|g| g.github.to_lowercase() == login)
280281
.map(|u| u.github_id))
281282
}
282283

src/zulip.rs

Lines changed: 136 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -172,103 +172,162 @@ async fn process_zulip_request(ctx: &Context, req: Request) -> anyhow::Result<Op
172172
handle_command(ctx, gh_id, &req.data, &req.message).await
173173
}
174174

175-
fn handle_command<'a>(
175+
async fn handle_command<'a>(
176176
ctx: &'a Context,
177-
gh_id: u64,
177+
mut gh_id: u64,
178178
command: &'a str,
179179
message_data: &'a Message,
180-
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<Option<String>>> + Send + 'a>>
181-
{
182-
Box::pin(async move {
183-
log::trace!("handling zulip command {:?}", command);
184-
let words: Vec<&str> = command.split_whitespace().collect();
180+
) -> anyhow::Result<Option<String>> {
181+
log::trace!("handling zulip command {:?}", command);
182+
let words: Vec<&str> = command.split_whitespace().collect();
185183

184+
// Missing stream means that this is a direct message
185+
if message_data.stream_id.is_none() {
186+
// Handle impersonation
187+
let mut impersonated = false;
186188
if let Some(&"as") = words.get(0) {
187-
return execute_for_other_user(&ctx, words.iter().skip(1).copied(), message_data)
188-
.await
189-
.map_err(|e| {
190-
format_err!("Failed to parse; expected `as <username> <command...>`: {e:?}.")
191-
});
189+
if let Some(username) = words.get(1) {
190+
let impersonated_gh_id = match get_id_for_username(&ctx.github, username)
191+
.await
192+
.context("getting ID of github user")?
193+
{
194+
Some(id) => id.try_into()?,
195+
None => anyhow::bail!("Can only authorize for other GitHub users."),
196+
};
197+
198+
// Impersonate => change actual gh_id
199+
if impersonated_gh_id != gh_id {
200+
impersonated = true;
201+
gh_id = impersonated_gh_id;
202+
}
203+
} else {
204+
return Err(anyhow::anyhow!(
205+
"Failed to parse command; expected `as <username> <command...>`."
206+
));
207+
}
192208
}
193209

194-
// Missing stream means that this is a direct message
195-
if message_data.stream_id.is_none() {
196-
let cmd = parse_cli::<ChatCommand, _>(words.into_iter())?;
197-
match cmd {
198-
ChatCommand::Acknowledge { identifier } => {
199-
acknowledge(&ctx, gh_id, (&identifier).into()).await
200-
}
201-
ChatCommand::Add { url, description } => {
202-
add_notification(&ctx, gh_id, &url, &description.join(" ")).await
203-
}
204-
ChatCommand::Move { from, to } => move_notification(&ctx, gh_id, from, to).await,
205-
ChatCommand::Meta { index, description } => {
206-
add_meta_notification(&ctx, gh_id, index, &description.join(" ")).await
207-
}
208-
ChatCommand::Whoami => whoami_cmd(&ctx, gh_id).await,
209-
ChatCommand::Lookup(cmd) => lookup_cmd(&ctx, cmd).await,
210-
ChatCommand::Work(cmd) => workqueue_commands(ctx, gh_id, cmd).await,
210+
let cmd = parse_cli::<ChatCommand, _>(words.into_iter())?;
211+
let output = match &cmd {
212+
ChatCommand::Acknowledge { identifier } => {
213+
acknowledge(&ctx, gh_id, identifier.into()).await
214+
}
215+
ChatCommand::Add { url, description } => {
216+
add_notification(&ctx, gh_id, &url, &description.join(" ")).await
211217
}
212-
} else {
213-
// We are in a stream, where someone wrote `@**triagebot** <command(s)>`
214-
let cmd_index = words
218+
ChatCommand::Move { from, to } => move_notification(&ctx, gh_id, *from, *to).await,
219+
ChatCommand::Meta { index, description } => {
220+
add_meta_notification(&ctx, gh_id, *index, &description.join(" ")).await
221+
}
222+
ChatCommand::Whoami => whoami_cmd(&ctx, gh_id).await,
223+
ChatCommand::Lookup(cmd) => lookup_cmd(&ctx, cmd).await,
224+
ChatCommand::Work(cmd) => workqueue_commands(ctx, gh_id, cmd).await,
225+
};
226+
227+
let output = output?;
228+
229+
// Let the impersonated person know about the impersonation if the command was sensitive
230+
if impersonated && is_sensitive_command(&cmd) {
231+
let impersonated_zulip_id = to_zulip_id(&ctx.github, gh_id)
232+
.await?
233+
.ok_or_else(|| anyhow::anyhow!("Zulip user for GitHub ID {gh_id} was not found"))?;
234+
let users = ctx.zulip.get_zulip_users().await?;
235+
let user = users
215236
.iter()
216-
.position(|w| *w == "@**triagebot**")
217-
.unwrap_or(words.len());
218-
let cmd_index = cmd_index + 1;
219-
if cmd_index >= words.len() {
220-
return Ok(Some("Unknown command".to_string()));
237+
.find(|m| m.user_id == impersonated_zulip_id)
238+
.ok_or_else(|| format_err!("Could not find Zulip user email."))?;
239+
240+
let sender = &message_data.sender_full_name;
241+
let message = format!(
242+
"{sender} ran `{command}` on your behalf. Output:\n{}",
243+
output.as_deref().unwrap_or("<empty>")
244+
);
245+
246+
MessageApiRequest {
247+
recipient: Recipient::Private {
248+
id: user.user_id,
249+
email: &user.email,
250+
},
251+
content: &message,
221252
}
222-
let cmd = parse_cli::<StreamCommand, _>(words[cmd_index..].into_iter().copied())?;
223-
match cmd {
224-
StreamCommand::EndTopic => {
225-
post_waiter(&ctx, message_data, WaitingMessage::end_topic())
226-
.await
227-
.map_err(|e| format_err!("Failed to await at this time: {e:?}"))
228-
}
229-
StreamCommand::EndMeeting => {
230-
post_waiter(&ctx, message_data, WaitingMessage::end_meeting())
231-
.await
232-
.map_err(|e| format_err!("Failed to await at this time: {e:?}"))
233-
}
234-
StreamCommand::Read => {
235-
post_waiter(&ctx, message_data, WaitingMessage::start_reading())
236-
.await
237-
.map_err(|e| format_err!("Failed to await at this time: {e:?}"))
238-
}
239-
StreamCommand::PingGoals {
240-
threshold,
241-
next_update,
242-
} => {
243-
if project_goals::check_project_goal_acl(&ctx.github, gh_id).await? {
244-
ping_project_goals_owners(
245-
&ctx.github,
246-
&ctx.zulip,
247-
false,
248-
threshold as i64,
249-
&format!("on {next_update}"),
250-
)
251-
.await
252-
.map_err(|e| format_err!("Failed to await at this time: {e:?}"))?;
253-
Ok(None)
254-
} else {
255-
Err(format_err!(
253+
.send(&ctx.zulip)
254+
.await?;
255+
}
256+
257+
Ok(output)
258+
} else {
259+
// We are in a stream, where someone wrote `@**triagebot** <command(s)>`
260+
let cmd_index = words
261+
.iter()
262+
.position(|w| *w == "@**triagebot**")
263+
.unwrap_or(words.len());
264+
let cmd_index = cmd_index + 1;
265+
if cmd_index >= words.len() {
266+
return Ok(Some("Unknown command".to_string()));
267+
}
268+
let cmd = parse_cli::<StreamCommand, _>(words[cmd_index..].into_iter().copied())?;
269+
match cmd {
270+
StreamCommand::EndTopic => post_waiter(&ctx, message_data, WaitingMessage::end_topic())
271+
.await
272+
.map_err(|e| format_err!("Failed to await at this time: {e:?}")),
273+
StreamCommand::EndMeeting => {
274+
post_waiter(&ctx, message_data, WaitingMessage::end_meeting())
275+
.await
276+
.map_err(|e| format_err!("Failed to await at this time: {e:?}"))
277+
}
278+
StreamCommand::Read => post_waiter(&ctx, message_data, WaitingMessage::start_reading())
279+
.await
280+
.map_err(|e| format_err!("Failed to await at this time: {e:?}")),
281+
StreamCommand::PingGoals {
282+
threshold,
283+
next_update,
284+
} => {
285+
if project_goals::check_project_goal_acl(&ctx.github, gh_id).await? {
286+
ping_project_goals_owners(
287+
&ctx.github,
288+
&ctx.zulip,
289+
false,
290+
threshold as i64,
291+
&format!("on {next_update}"),
292+
)
293+
.await
294+
.map_err(|e| format_err!("Failed to await at this time: {e:?}"))?;
295+
Ok(None)
296+
} else {
297+
Err(format_err!(
256298
"That command is only permitted for those running the project-goal program.",
257299
))
258-
}
259300
}
260-
StreamCommand::DocsUpdate => trigger_docs_update(message_data, &ctx.zulip),
261301
}
302+
StreamCommand::DocsUpdate => trigger_docs_update(message_data, &ctx.zulip),
262303
}
263-
})
304+
}
305+
}
306+
307+
/// Returns true if we should notify user who was impersonated by someone who executed this command.
308+
/// More or less, the following holds: `sensitive` == `not read-only`.
309+
fn is_sensitive_command(cmd: &ChatCommand) -> bool {
310+
match cmd {
311+
ChatCommand::Acknowledge { .. }
312+
| ChatCommand::Add { .. }
313+
| ChatCommand::Move { .. }
314+
| ChatCommand::Meta { .. } => true,
315+
ChatCommand::Whoami => false,
316+
ChatCommand::Lookup(_) => false,
317+
ChatCommand::Work(cmd) => match cmd {
318+
WorkqueueCmd::Show => false,
319+
WorkqueueCmd::SetPrLimit { .. } => true,
320+
WorkqueueCmd::SetRotationMode { .. } => true,
321+
},
322+
}
264323
}
265324

266325
/// Commands for working with the workqueue, e.g. showing how many PRs are assigned
267326
/// or modifying the PR review assignment limit.
268327
async fn workqueue_commands(
269328
ctx: &Context,
270329
gh_id: u64,
271-
cmd: WorkqueueCmd,
330+
cmd: &WorkqueueCmd,
272331
) -> anyhow::Result<Option<String>> {
273332
let db_client = ctx.db.get().await;
274333

@@ -326,7 +385,7 @@ async fn workqueue_commands(
326385
WorkqueueCmd::SetPrLimit { limit } => {
327386
let max_assigned_prs = match limit {
328387
WorkqueueLimit::Unlimited => None,
329-
WorkqueueLimit::Limit(limit) => Some(limit),
388+
WorkqueueLimit::Limit(limit) => Some(*limit),
330389
};
331390
upsert_review_prefs(
332391
&db_client,
@@ -419,7 +478,7 @@ async fn whoami_cmd(ctx: &Context, gh_id: u64) -> anyhow::Result<Option<String>>
419478
Ok(Some(output))
420479
}
421480

422-
async fn lookup_cmd(ctx: &Context, cmd: LookupCmd) -> anyhow::Result<Option<String>> {
481+
async fn lookup_cmd(ctx: &Context, cmd: &LookupCmd) -> anyhow::Result<Option<String>> {
423482
let username = match &cmd {
424483
LookupCmd::Zulip { github_username } => github_username.clone(),
425484
// Usernames could contain spaces, so rejoin everything to serve as the username.
@@ -545,82 +604,6 @@ async fn lookup_zulip_username(ctx: &Context, gh_username: &str) -> anyhow::Resu
545604
))
546605
}
547606

548-
// This does two things:
549-
// * execute the command for the other user
550-
// * tell the user executed for that a command was run as them by the user
551-
// given.
552-
async fn execute_for_other_user(
553-
ctx: &Context,
554-
mut words: impl Iterator<Item = &str>,
555-
message_data: &Message,
556-
) -> anyhow::Result<Option<String>> {
557-
// username is a GitHub username, not a Zulip username
558-
let username = match words.next() {
559-
Some(username) => username,
560-
None => anyhow::bail!("no username provided"),
561-
};
562-
let user_id = match get_id_for_username(&ctx.github, username)
563-
.await
564-
.context("getting ID of github user")?
565-
{
566-
Some(id) => id.try_into().unwrap(),
567-
None => anyhow::bail!("Can only authorize for other GitHub users."),
568-
};
569-
let mut command = words.fold(String::new(), |mut acc, piece| {
570-
acc.push_str(piece);
571-
acc.push(' ');
572-
acc
573-
});
574-
let command = if command.is_empty() {
575-
anyhow::bail!("no command provided")
576-
} else {
577-
assert_eq!(command.pop(), Some(' ')); // pop trailing space
578-
command
579-
};
580-
581-
let members = ctx
582-
.zulip
583-
.get_zulip_users()
584-
.await
585-
.map_err(|e| format_err!("Failed to get list of zulip users: {e:?}."))?;
586-
587-
// Map GitHub `user_id` to `zulip_user_id`.
588-
let zulip_user_id = match to_zulip_id(&ctx.github, user_id).await {
589-
Ok(Some(id)) => id as u64,
590-
Ok(None) => anyhow::bail!("Could not find Zulip ID for GitHub ID: {user_id}"),
591-
Err(e) => anyhow::bail!("Could not find Zulip ID for GitHub id {user_id}: {e:?}"),
592-
};
593-
594-
let user = members
595-
.iter()
596-
.find(|m| m.user_id == zulip_user_id)
597-
.ok_or_else(|| format_err!("Could not find Zulip user email."))?;
598-
599-
let output = handle_command(ctx, user_id, &command, message_data)
600-
.await?
601-
.unwrap_or_default();
602-
603-
// At this point, the command has been run.
604-
let sender = &message_data.sender_full_name;
605-
let message = format!("{sender} ran `{command}` with output `{output}` as you.");
606-
607-
let res = MessageApiRequest {
608-
recipient: Recipient::Private {
609-
id: user.user_id,
610-
email: &user.email,
611-
},
612-
content: &message,
613-
}
614-
.send(&ctx.zulip)
615-
.await;
616-
617-
if let Err(err) = res {
618-
log::error!("Failed to notify real user about command: {:?}", err);
619-
}
620-
621-
Ok(Some(output))
622-
}
623-
624607
#[derive(serde::Serialize)]
625608
pub(crate) struct MessageApiRequest<'a> {
626609
pub(crate) recipient: Recipient<'a>,

0 commit comments

Comments
 (0)