collab: Restrict usage of the LLM service to accounts older than 30 days (#16133)

Marshall Bowers and Max created

This PR restricts usage of the LLM service to accounts older than 30
days.

We now store the GitHub user's `created_at` timestamp to check the
GitHub account age. If this is not setโ€”which it won't be for existing
usersโ€”then we use the `created_at` timestamp in the Zed database.

Release Notes:

- N/A

---------

Co-authored-by: Max <max@zed.dev>

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql                  |  3 
crates/collab/migrations/20240812204045_add_github_user_created_at_to_users.sql |  1 
crates/collab/src/api.rs                                                        |  2 
crates/collab/src/api/contributors.rs                                           |  1 
crates/collab/src/db/queries/contributors.rs                                    |  2 
crates/collab/src/db/queries/users.rs                                           | 14 
crates/collab/src/db/tables/user.rs                                             |  6 
crates/collab/src/db/tests/contributor_tests.rs                                 |  8 
crates/collab/src/db/tests/db_tests.rs                                          | 17 
crates/collab/src/rpc.rs                                                        | 11 
crates/collab/src/seed.rs                                                       |  3 
crates/collab/src/tests/channel_guest_tests.rs                                  |  5 
12 files changed, 64 insertions(+), 9 deletions(-)

Detailed changes

crates/collab/migrations.sqlite/20221109000000_test_schema.sql ๐Ÿ”—

@@ -10,7 +10,8 @@ CREATE TABLE "users" (
     "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
     "metrics_id" TEXT,
     "github_user_id" INTEGER,
-    "accepted_tos_at" TIMESTAMP WITHOUT TIME ZONE
+    "accepted_tos_at" TIMESTAMP WITHOUT TIME ZONE,
+    "github_user_created_at" TIMESTAMP WITHOUT TIME ZONE
 );
 CREATE UNIQUE INDEX "index_users_github_login" ON "users" ("github_login");
 CREATE UNIQUE INDEX "index_invite_code_users" ON "users" ("invite_code");

crates/collab/src/api.rs ๐Ÿ”—

@@ -111,6 +111,7 @@ struct AuthenticatedUserParams {
     github_user_id: Option<i32>,
     github_login: String,
     github_email: Option<String>,
+    github_user_created_at: Option<chrono::DateTime<chrono::Utc>>,
 }
 
 #[derive(Debug, Serialize)]
@@ -131,6 +132,7 @@ async fn get_authenticated_user(
             &params.github_login,
             params.github_user_id,
             params.github_email.as_deref(),
+            params.github_user_created_at,
             initial_channel_id,
         )
         .await?;

crates/collab/src/api/contributors.rs ๐Ÿ”—

@@ -115,6 +115,7 @@ async fn add_contributor(
             &params.github_login,
             params.github_user_id,
             params.github_email.as_deref(),
+            params.github_user_created_at,
             initial_channel_id,
         )
         .await

crates/collab/src/db/queries/contributors.rs ๐Ÿ”—

@@ -65,6 +65,7 @@ impl Database {
         github_login: &str,
         github_user_id: Option<i32>,
         github_email: Option<&str>,
+        github_user_created_at: Option<DateTimeUtc>,
         initial_channel_id: Option<ChannelId>,
     ) -> Result<()> {
         self.transaction(|tx| async move {
@@ -73,6 +74,7 @@ impl Database {
                     github_login,
                     github_user_id,
                     github_email,
+                    github_user_created_at.map(|time| time.naive_utc()),
                     initial_channel_id,
                     &tx,
                 )

crates/collab/src/db/queries/users.rs ๐Ÿ”—

@@ -1,3 +1,5 @@
+use chrono::NaiveDateTime;
+
 use super::*;
 
 impl Database {
@@ -99,6 +101,7 @@ impl Database {
         github_login: &str,
         github_user_id: Option<i32>,
         github_email: Option<&str>,
+        github_user_created_at: Option<DateTimeUtc>,
         initial_channel_id: Option<ChannelId>,
     ) -> Result<User> {
         self.transaction(|tx| async move {
@@ -106,6 +109,7 @@ impl Database {
                 github_login,
                 github_user_id,
                 github_email,
+                github_user_created_at.map(|created_at| created_at.naive_utc()),
                 initial_channel_id,
                 &tx,
             )
@@ -119,6 +123,7 @@ impl Database {
         github_login: &str,
         github_user_id: Option<i32>,
         github_email: Option<&str>,
+        github_user_created_at: Option<NaiveDateTime>,
         initial_channel_id: Option<ChannelId>,
         tx: &DatabaseTransaction,
     ) -> Result<User> {
@@ -130,6 +135,10 @@ impl Database {
             {
                 let mut user_by_github_user_id = user_by_github_user_id.into_active_model();
                 user_by_github_user_id.github_login = ActiveValue::set(github_login.into());
+                if github_user_created_at.is_some() {
+                    user_by_github_user_id.github_user_created_at =
+                        ActiveValue::set(github_user_created_at);
+                }
                 Ok(user_by_github_user_id.update(tx).await?)
             } else if let Some(user_by_github_login) = user::Entity::find()
                 .filter(user::Column::GithubLogin.eq(github_login))
@@ -138,12 +147,17 @@ impl Database {
             {
                 let mut user_by_github_login = user_by_github_login.into_active_model();
                 user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id));
+                if github_user_created_at.is_some() {
+                    user_by_github_login.github_user_created_at =
+                        ActiveValue::set(github_user_created_at);
+                }
                 Ok(user_by_github_login.update(tx).await?)
             } else {
                 let user = user::Entity::insert(user::ActiveModel {
                     email_address: ActiveValue::set(github_email.map(|email| email.into())),
                     github_login: ActiveValue::set(github_login.into()),
                     github_user_id: ActiveValue::set(Some(github_user_id)),
+                    github_user_created_at: ActiveValue::set(github_user_created_at),
                     admin: ActiveValue::set(false),
                     invite_count: ActiveValue::set(0),
                     invite_code: ActiveValue::set(None),

crates/collab/src/db/tables/user.rs ๐Ÿ”—

@@ -1,4 +1,5 @@
 use crate::db::UserId;
+use chrono::NaiveDateTime;
 use sea_orm::entity::prelude::*;
 use serde::Serialize;
 
@@ -10,6 +11,7 @@ pub struct Model {
     pub id: UserId,
     pub github_login: String,
     pub github_user_id: Option<i32>,
+    pub github_user_created_at: Option<NaiveDateTime>,
     pub email_address: Option<String>,
     pub admin: bool,
     pub invite_code: Option<String>,
@@ -17,8 +19,8 @@ pub struct Model {
     pub inviter_id: Option<UserId>,
     pub connected_once: bool,
     pub metrics_id: Uuid,
-    pub created_at: DateTime,
-    pub accepted_tos_at: Option<DateTime>,
+    pub created_at: NaiveDateTime,
+    pub accepted_tos_at: Option<NaiveDateTime>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

crates/collab/src/db/tests/contributor_tests.rs ๐Ÿ”—

@@ -1,3 +1,5 @@
+use chrono::Utc;
+
 use super::Database;
 use crate::{db::NewUserParams, test_both_dbs};
 use std::sync::Arc;
@@ -22,7 +24,8 @@ async fn test_contributors(db: &Arc<Database>) {
 
     assert_eq!(db.get_contributors().await.unwrap(), Vec::<String>::new());
 
-    db.add_contributor("user1", Some(1), None, None)
+    let user1_created_at = Utc::now();
+    db.add_contributor("user1", Some(1), None, Some(user1_created_at), None)
         .await
         .unwrap();
     assert_eq!(
@@ -30,7 +33,8 @@ async fn test_contributors(db: &Arc<Database>) {
         vec!["user1".to_string()]
     );
 
-    db.add_contributor("user2", Some(2), None, None)
+    let user2_created_at = Utc::now();
+    db.add_contributor("user2", Some(2), None, Some(user2_created_at), None)
         .await
         .unwrap();
     assert_eq!(

crates/collab/src/db/tests/db_tests.rs ๐Ÿ”—

@@ -1,5 +1,6 @@
 use super::*;
 use crate::test_both_dbs;
+use chrono::Utc;
 use pretty_assertions::{assert_eq, assert_ne};
 use std::sync::Arc;
 
@@ -100,7 +101,13 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
         .user_id;
 
     let user = db
-        .get_or_create_user_by_github_account("the-new-login2", Some(102), None, None)
+        .get_or_create_user_by_github_account(
+            "the-new-login2",
+            Some(102),
+            None,
+            Some(Utc::now()),
+            None,
+        )
         .await
         .unwrap();
     assert_eq!(user.id, user_id2);
@@ -108,7 +115,13 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
     assert_eq!(user.github_user_id, Some(102));
 
     let user = db
-        .get_or_create_user_by_github_account("login3", Some(103), Some("user3@example.com"), None)
+        .get_or_create_user_by_github_account(
+            "login3",
+            Some(103),
+            Some("user3@example.com"),
+            Some(Utc::now()),
+            None,
+        )
         .await
         .unwrap();
     assert_eq!(&user.github_login, "login3");

crates/collab/src/rpc.rs ๐Ÿ”—

@@ -4907,6 +4907,9 @@ async fn accept_terms_of_service(
     Ok(())
 }
 
+/// The minimum account age an account must have in order to use the LLM service.
+const MIN_ACCOUNT_AGE_FOR_LLM_USE: chrono::Duration = chrono::Duration::days(30);
+
 async fn get_llm_api_token(
     _request: proto::GetLlmToken,
     response: Response<proto::GetLlmToken>,
@@ -4928,6 +4931,14 @@ async fn get_llm_api_token(
         Err(anyhow!("terms of service not accepted"))?
     }
 
+    let mut account_created_at = user.created_at;
+    if let Some(github_created_at) = user.github_user_created_at {
+        account_created_at = account_created_at.min(github_created_at);
+    }
+    if Utc::now().naive_utc() - account_created_at < MIN_ACCOUNT_AGE_FOR_LLM_USE {
+        Err(anyhow!("account too young"))?
+    }
+
     let token = LlmTokenClaims::create(
         user.id,
         session.is_staff(),

crates/collab/src/seed.rs ๐Ÿ”—

@@ -1,6 +1,7 @@
 use crate::db::{self, ChannelRole, NewUserParams};
 
 use anyhow::Context;
+use chrono::{DateTime, Utc};
 use db::Database;
 use serde::{de::DeserializeOwned, Deserialize};
 use std::{fmt::Write, fs, path::Path};
@@ -12,6 +13,7 @@ struct GitHubUser {
     id: i32,
     login: String,
     email: Option<String>,
+    created_at: DateTime<Utc>,
 }
 
 #[derive(Deserialize)]
@@ -107,6 +109,7 @@ pub async fn seed(config: &Config, db: &Database, force: bool) -> anyhow::Result
                     &github_user.login,
                     Some(github_user.id),
                     github_user.email.as_deref(),
+                    Some(github_user.created_at),
                     None,
                 )
                 .await

crates/collab/src/tests/channel_guest_tests.rs ๐Ÿ”—

@@ -1,5 +1,6 @@
 use crate::{db::ChannelId, tests::TestServer};
 use call::ActiveCall;
+use chrono::Utc;
 use editor::Editor;
 use gpui::{BackgroundExecutor, TestAppContext};
 use rpc::proto;
@@ -167,7 +168,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes
     server
         .app_state
         .db
-        .get_or_create_user_by_github_account("user_b", Some(100), None, None)
+        .get_or_create_user_by_github_account("user_b", Some(100), None, Some(Utc::now()), None)
         .await
         .unwrap();
 
@@ -265,7 +266,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes
     server
         .app_state
         .db
-        .add_contributor("user_b", Some(100), None, None)
+        .add_contributor("user_b", Some(100), None, Some(Utc::now()), None)
         .await
         .unwrap();