From 38bdf7ad92f62c04036f72abfa10b281eec90b1f Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 17 Oct 2022 17:48:12 -0700 Subject: [PATCH 1/4] Remove unused env vars from collab k8s manifest --- crates/collab/k8s/manifest.template.yml | 32 ------------------------- crates/collab/src/main.rs | 2 -- 2 files changed, 34 deletions(-) diff --git a/crates/collab/k8s/manifest.template.yml b/crates/collab/k8s/manifest.template.yml index 6f3eb26df454c6ae34e4cbdc614daf8441ef2e99..628cf92506c92b450c3bb60645df8e740d10009b 100644 --- a/crates/collab/k8s/manifest.template.yml +++ b/crates/collab/k8s/manifest.template.yml @@ -65,31 +65,6 @@ spec: secretKeyRef: name: database key: url - - name: SESSION_SECRET - valueFrom: - secretKeyRef: - name: session - key: secret - - name: GITHUB_APP_ID - valueFrom: - secretKeyRef: - name: github - key: appId - - name: GITHUB_CLIENT_ID - valueFrom: - secretKeyRef: - name: github - key: clientId - - name: GITHUB_CLIENT_SECRET - valueFrom: - secretKeyRef: - name: github - key: clientSecret - - name: GITHUB_PRIVATE_KEY - valueFrom: - secretKeyRef: - name: github - key: privateKey - name: API_TOKEN valueFrom: secretKeyRef: @@ -101,13 +76,6 @@ spec: value: ${RUST_LOG} - name: LOG_JSON value: "true" - - name: HONEYCOMB_DATASET - value: "collab" - - name: HONEYCOMB_API_KEY - valueFrom: - secretKeyRef: - name: honeycomb - key: apiKey securityContext: capabilities: # FIXME - Switch to the more restrictive `PERFMON` capability. diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 272d52cc954140b71d18d2b1296e6613753be581..df48a08a539390522d234760c04a16628acab668 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -28,8 +28,6 @@ pub struct Config { pub database_url: String, pub api_token: String, pub invite_link_prefix: String, - pub honeycomb_api_key: Option, - pub honeycomb_dataset: Option, pub rust_log: Option, pub log_json: Option, } From 5e57a33df70ccd0372a453c0afb6d20dbec7ab7a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 18 Oct 2022 13:02:30 -0700 Subject: [PATCH 2/4] Store entire Config struct on collab AppState --- crates/collab/src/api.rs | 2 +- crates/collab/src/integration_tests.rs | 3 +-- crates/collab/src/main.rs | 12 +++++------- crates/collab/src/rpc.rs | 9 ++++++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index 08dfa91ba98df3b1c54e8690dbd31bd169da936c..922ef965314147adc2cc16f448073b933541e079 100644 --- a/crates/collab/src/api.rs +++ b/crates/collab/src/api.rs @@ -76,7 +76,7 @@ pub async fn validate_api_token(req: Request, next: Next) -> impl IntoR let state = req.extensions().get::>().unwrap(); - if token != state.api_token { + if token != state.config.api_token { Err(Error::Http( StatusCode::UNAUTHORIZED, "invalid authorization token".to_string(), diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs index 9bf4afc5db3b84141f5c6907f8b35632fcfa6af2..d5ba61ffba1ac7fdf422152a7eb23be6c380d936 100644 --- a/crates/collab/src/integration_tests.rs +++ b/crates/collab/src/integration_tests.rs @@ -6357,8 +6357,7 @@ impl TestServer { async fn build_app_state(test_db: &TestDb) -> Arc { Arc::new(AppState { db: test_db.db().clone(), - api_token: Default::default(), - invite_link_prefix: Default::default(), + config: Default::default(), }) } diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index df48a08a539390522d234760c04a16628acab668..1c3a5fa20d4d63a836a3f1660f2663bfa1684fdb 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -34,17 +34,15 @@ pub struct Config { pub struct AppState { db: Arc, - api_token: String, - invite_link_prefix: String, + config: Config, } impl AppState { - async fn new(config: &Config) -> Result> { + async fn new(config: Config) -> Result> { let db = PostgresDb::new(&config.database_url, 5).await?; let this = Self { db: Arc::new(db), - api_token: config.api_token.clone(), - invite_link_prefix: config.invite_link_prefix.clone(), + config, }; Ok(Arc::new(this)) } @@ -61,9 +59,9 @@ async fn main() -> Result<()> { let config = envy::from_env::().expect("error loading config"); init_tracing(&config); - let state = AppState::new(&config).await?; + let state = AppState::new(config).await?; - let listener = TcpListener::bind(&format!("0.0.0.0:{}", config.http_port)) + let listener = TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port)) .expect("failed to bind TCP listener"); let rpc_server = rpc::Server::new(state.clone(), None); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 1662802c23693088c01b7f86ea926f496e76a7c3..f2ffb1dc5eeec21b1e05064f22daf19366d72bed 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -397,7 +397,7 @@ impl Server { if let Some((code, count)) = invite_code { this.peer.send(connection_id, proto::UpdateInviteInfo { - url: format!("{}{}", this.app_state.invite_link_prefix, code), + url: format!("{}{}", this.app_state.config.invite_link_prefix, code), count, })?; } @@ -561,7 +561,7 @@ impl Server { self.peer.send( connection_id, proto::UpdateInviteInfo { - url: format!("{}{}", self.app_state.invite_link_prefix, &code), + url: format!("{}{}", self.app_state.config.invite_link_prefix, &code), count: user.invite_count as u32, }, )?; @@ -579,7 +579,10 @@ impl Server { self.peer.send( connection_id, proto::UpdateInviteInfo { - url: format!("{}{}", self.app_state.invite_link_prefix, invite_code), + url: format!( + "{}{}", + self.app_state.config.invite_link_prefix, invite_code + ), count: user.invite_count as u32, }, )?; From c410935c9ceb936d95e59607e372583e61308477 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 18 Oct 2022 17:36:54 -0700 Subject: [PATCH 3/4] Allow impersonating users via the api token, bypassing oauth --- crates/client/src/client.rs | 111 ++++++++++++++++++++++++++-------- crates/collab/.env.toml | 2 - crates/collab/src/api.rs | 4 +- crates/collab/src/auth.rs | 14 +++-- script/zed-with-local-servers | 2 +- 5 files changed, 98 insertions(+), 35 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 38214a6ac308e86181c39b0f1a9562d136937261..9cfccba37f9963beaeea18e9db1a6e4f012cc7ff 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -13,11 +13,13 @@ use async_tungstenite::tungstenite::{ http::{Request, StatusCode}, }; use db::Db; -use futures::{future::LocalBoxFuture, FutureExt, SinkExt, StreamExt, TryStreamExt}; +use futures::{future::LocalBoxFuture, AsyncReadExt, FutureExt, SinkExt, StreamExt, TryStreamExt}; use gpui::{ - actions, serde_json::Value, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, - AnyWeakViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, - MutableAppContext, Task, View, ViewContext, ViewHandle, + actions, + serde_json::{self, Value}, + AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AppContext, + AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task, View, ViewContext, + ViewHandle, }; use http::HttpClient; use lazy_static::lazy_static; @@ -25,6 +27,7 @@ use parking_lot::RwLock; use postage::watch; use rand::prelude::*; use rpc::proto::{AnyTypedEnvelope, EntityMessage, EnvelopedMessage, RequestMessage}; +use serde::Deserialize; use std::{ any::TypeId, collections::HashMap, @@ -50,6 +53,9 @@ lazy_static! { pub static ref IMPERSONATE_LOGIN: Option = std::env::var("ZED_IMPERSONATE") .ok() .and_then(|s| if s.is_empty() { None } else { Some(s) }); + pub static ref ADMIN_API_TOKEN: Option = std::env::var("ZED_ADMIN_API_TOKEN") + .ok() + .and_then(|s| if s.is_empty() { None } else { Some(s) }); } pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894"; @@ -919,6 +925,32 @@ impl Client { self.establish_websocket_connection(credentials, cx) } + async fn get_rpc_url(http: Arc) -> Result { + let rpc_response = http + .get( + &(format!("{}/rpc", *ZED_SERVER_URL)), + Default::default(), + false, + ) + .await?; + if !rpc_response.status().is_redirection() { + Err(anyhow!( + "unexpected /rpc response status {}", + rpc_response.status() + ))? + } + + let rpc_url = rpc_response + .headers() + .get("Location") + .ok_or_else(|| anyhow!("missing location header in /rpc response"))? + .to_str() + .map_err(EstablishConnectionError::other)? + .to_string(); + + Url::parse(&rpc_url).context("invalid rpc url") + } + fn establish_websocket_connection( self: &Arc, credentials: &Credentials, @@ -933,28 +965,7 @@ impl Client { let http = self.http.clone(); cx.background().spawn(async move { - let mut rpc_url = format!("{}/rpc", *ZED_SERVER_URL); - let rpc_response = http.get(&rpc_url, Default::default(), false).await?; - if rpc_response.status().is_redirection() { - rpc_url = rpc_response - .headers() - .get("Location") - .ok_or_else(|| anyhow!("missing location header in /rpc response"))? - .to_str() - .map_err(EstablishConnectionError::other)? - .to_string(); - } - // Until we switch the zed.dev domain to point to the new Next.js app, there - // will be no redirect required, and the app will connect directly to - // wss://zed.dev/rpc. - else if rpc_response.status() != StatusCode::UPGRADE_REQUIRED { - Err(anyhow!( - "unexpected /rpc response status {}", - rpc_response.status() - ))? - } - - let mut rpc_url = Url::parse(&rpc_url).context("invalid rpc url")?; + let mut rpc_url = Self::get_rpc_url(http).await?; let rpc_host = rpc_url .host_str() .zip(rpc_url.port_or_known_default()) @@ -997,6 +1008,7 @@ impl Client { let platform = cx.platform(); let executor = cx.background(); let telemetry = self.telemetry.clone(); + let http = self.http.clone(); executor.clone().spawn(async move { // Generate a pair of asymmetric encryption keys. The public key will be used by the // zed server to encrypt the user's access token, so that it can'be intercepted by @@ -1006,6 +1018,10 @@ impl Client { let public_key_string = String::try_from(public_key).expect("failed to serialize public key for auth"); + if let Some((login, token)) = IMPERSONATE_LOGIN.as_ref().zip(ADMIN_API_TOKEN.as_ref()) { + return Self::authenticate_as_admin(http, login.clone(), token.clone()).await; + } + // Start an HTTP server to receive the redirect from Zed's sign-in page. let server = tiny_http::Server::http("127.0.0.1:0").expect("failed to find open port"); let port = server.server_addr().port(); @@ -1084,6 +1100,49 @@ impl Client { }) } + async fn authenticate_as_admin( + http: Arc, + login: String, + mut api_token: String, + ) -> Result { + let mut url = Self::get_rpc_url(http.clone()).await?; + url.set_path("/user"); + url.set_query(Some(&format!("github_login={login}"))); + let request = Request::get(url.as_str()) + .header("Authorization", format!("token {api_token}")) + .body("".into())?; + + let mut response = http.send(request).await?; + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + if !response.status().is_success() { + Err(anyhow!( + "admin user request failed {} - {}", + response.status().as_u16(), + body, + ))?; + } + + #[derive(Deserialize)] + struct AuthenticatedUserResponse { + user: User, + } + + #[derive(Deserialize)] + struct User { + id: u64, + } + + let response: AuthenticatedUserResponse = serde_json::from_str(&body)?; + + api_token.insert_str(0, "ADMIN_TOKEN:"); + Ok(Credentials { + user_id: response.user.id, + access_token: api_token, + }) + } + pub fn disconnect(self: &Arc, cx: &AsyncAppContext) -> Result<()> { let conn_id = self.connection_id()?; self.peer.disconnect(conn_id); diff --git a/crates/collab/.env.toml b/crates/collab/.env.toml index 98198eb7759820c5ec0e5aeb07416d3564800567..93a0a7f961a65edcf59eb13c4c85c4a566afa440 100644 --- a/crates/collab/.env.toml +++ b/crates/collab/.env.toml @@ -3,7 +3,5 @@ HTTP_PORT = 8080 API_TOKEN = "secret" INVITE_LINK_PREFIX = "http://localhost:3000/invites/" -# HONEYCOMB_API_KEY= -# HONEYCOMB_DATASET= # RUST_LOG=info # LOG_JSON=true diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index 922ef965314147adc2cc16f448073b933541e079..5d28fdf668ecb3ad683d99f6f05935905420d7aa 100644 --- a/crates/collab/src/api.rs +++ b/crates/collab/src/api.rs @@ -88,7 +88,7 @@ pub async fn validate_api_token(req: Request, next: Next) -> impl IntoR #[derive(Debug, Deserialize)] struct AuthenticatedUserParams { - github_user_id: i32, + github_user_id: Option, github_login: String, } @@ -104,7 +104,7 @@ async fn get_authenticated_user( ) -> Result> { let user = app .db - .get_user_by_github_account(¶ms.github_login, Some(params.github_user_id)) + .get_user_by_github_account(¶ms.github_login, params.github_user_id) .await? .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "user not found".into()))?; let metrics_id = app.db.get_user_metrics_id(user.id).await?; diff --git a/crates/collab/src/auth.rs b/crates/collab/src/auth.rs index a1fac6a2b6abc5f49403aaa0fbbfb7b44fc22c8f..e9e2855f1c2bb707d9c70eaffce810c4a0d49c81 100644 --- a/crates/collab/src/auth.rs +++ b/crates/collab/src/auth.rs @@ -41,12 +41,18 @@ pub async fn validate_header(mut req: Request, next: Next) -> impl Into ) })?; - let state = req.extensions().get::>().unwrap(); let mut credentials_valid = false; - for password_hash in state.db.get_access_token_hashes(user_id).await? { - if verify_access_token(access_token, &password_hash)? { + let state = req.extensions().get::>().unwrap(); + if let Some(admin_token) = access_token.strip_prefix("ADMIN_TOKEN:") { + if state.config.api_token == admin_token { credentials_valid = true; - break; + } + } else { + for password_hash in state.db.get_access_token_hashes(user_id).await? { + if verify_access_token(access_token, &password_hash)? { + credentials_valid = true; + break; + } } } diff --git a/script/zed-with-local-servers b/script/zed-with-local-servers index 0796b7808e1d16e2f523475d5aad5e82f265a5fb..ed07862d30169059957c50d259fa3499ee389996 100755 --- a/script/zed-with-local-servers +++ b/script/zed-with-local-servers @@ -1 +1 @@ -ZED_SERVER_URL=http://localhost:3000 cargo run $@ +ZED_ADMIN_API_TOKEN=secret ZED_SERVER_URL=http://localhost:3000 cargo run $@ From 83e4e269896e84c43f64e523f02973a4c41f9673 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 19 Oct 2022 13:27:14 -0700 Subject: [PATCH 4/4] Allow setting ZED_SERVER_URL to URL of a collab server --- crates/client/src/client.rs | 68 ++++++++++++++++++++----------------- crates/collab/src/auth.rs | 11 +++--- 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 9cfccba37f9963beaeea18e9db1a6e4f012cc7ff..64075472cdc7b39090045cd7af233f6eea39a91f 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -926,29 +926,34 @@ impl Client { } async fn get_rpc_url(http: Arc) -> Result { - let rpc_response = http - .get( - &(format!("{}/rpc", *ZED_SERVER_URL)), - Default::default(), - false, - ) - .await?; - if !rpc_response.status().is_redirection() { + let url = format!("{}/rpc", *ZED_SERVER_URL); + let response = http.get(&url, Default::default(), false).await?; + + // Normally, ZED_SERVER_URL is set to the URL of zed.dev website. + // The website's /rpc endpoint redirects to a collab server's /rpc endpoint, + // which requires authorization via an HTTP header. + // + // For testing purposes, ZED_SERVER_URL can also set to the direct URL of + // of a collab server. In that case, a request to the /rpc endpoint will + // return an 'unauthorized' response. + let collab_url = if response.status().is_redirection() { + response + .headers() + .get("Location") + .ok_or_else(|| anyhow!("missing location header in /rpc response"))? + .to_str() + .map_err(EstablishConnectionError::other)? + .to_string() + } else if response.status() == StatusCode::UNAUTHORIZED { + url + } else { Err(anyhow!( "unexpected /rpc response status {}", - rpc_response.status() + response.status() ))? - } - - let rpc_url = rpc_response - .headers() - .get("Location") - .ok_or_else(|| anyhow!("missing location header in /rpc response"))? - .to_str() - .map_err(EstablishConnectionError::other)? - .to_string(); + }; - Url::parse(&rpc_url).context("invalid rpc url") + Url::parse(&collab_url).context("invalid rpc url") } fn establish_websocket_connection( @@ -1105,6 +1110,18 @@ impl Client { login: String, mut api_token: String, ) -> Result { + #[derive(Deserialize)] + struct AuthenticatedUserResponse { + user: User, + } + + #[derive(Deserialize)] + struct User { + id: u64, + } + + // Use the collab server's admin API to retrieve the id + // of the impersonated user. let mut url = Self::get_rpc_url(http.clone()).await?; url.set_path("/user"); url.set_query(Some(&format!("github_login={login}"))); @@ -1115,7 +1132,6 @@ impl Client { let mut response = http.send(request).await?; let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; - if !response.status().is_success() { Err(anyhow!( "admin user request failed {} - {}", @@ -1123,19 +1139,9 @@ impl Client { body, ))?; } - - #[derive(Deserialize)] - struct AuthenticatedUserResponse { - user: User, - } - - #[derive(Deserialize)] - struct User { - id: u64, - } - let response: AuthenticatedUserResponse = serde_json::from_str(&body)?; + // Use the admin API token to authenticate as the impersonated user. api_token.insert_str(0, "ADMIN_TOKEN:"); Ok(Credentials { user_id: response.user.id, diff --git a/crates/collab/src/auth.rs b/crates/collab/src/auth.rs index e9e2855f1c2bb707d9c70eaffce810c4a0d49c81..9081fe1f1e793bab5e7825941ce198f8c0a14a67 100644 --- a/crates/collab/src/auth.rs +++ b/crates/collab/src/auth.rs @@ -1,7 +1,7 @@ -use std::sync::Arc; - -use super::db::{self, UserId}; -use crate::{AppState, Error, Result}; +use crate::{ + db::{self, UserId}, + AppState, Error, Result, +}; use anyhow::{anyhow, Context}; use axum::{ http::{self, Request, StatusCode}, @@ -13,6 +13,7 @@ use scrypt::{ password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Scrypt, }; +use std::sync::Arc; pub async fn validate_header(mut req: Request, next: Next) -> impl IntoResponse { let mut auth_header = req @@ -21,7 +22,7 @@ pub async fn validate_header(mut req: Request, next: Next) -> impl Into .and_then(|header| header.to_str().ok()) .ok_or_else(|| { Error::Http( - StatusCode::BAD_REQUEST, + StatusCode::UNAUTHORIZED, "missing authorization header".to_string(), ) })?