From aa1cafc3f2134b5c10472629e30d9f11b2352fd6 Mon Sep 17 00:00:00 2001 From: Michiel Date: Wed, 18 Mar 2026 12:16:14 +0100 Subject: [PATCH 1/9] Block API users in backend --- migrations/20260311155322_block_users.sql | 1 + src/api/api_keys.rs | 10 ++++++ src/api/api_users.rs | 24 ++++++++++--- src/api/auth.rs | 40 +++++++++++++++++----- src/api/invites.rs | 7 ++++ src/api/messages.rs | 16 +++++++++ src/api/oauth/handlers.rs | 8 ++++- src/api/organizations.rs | 29 +++++++++++++++- src/fixtures/api_users.sql | 8 +++++ src/models/api_user.rs | 41 ++++++++++++++++------- 10 files changed, 157 insertions(+), 27 deletions(-) create mode 100644 migrations/20260311155322_block_users.sql diff --git a/migrations/20260311155322_block_users.sql b/migrations/20260311155322_block_users.sql new file mode 100644 index 00000000..3aedb1fd --- /dev/null +++ b/migrations/20260311155322_block_users.sql @@ -0,0 +1 @@ +ALTER TABLE api_users ADD blocked boolean NOT NULL DEFAULT false; diff --git a/src/api/api_keys.rs b/src/api/api_keys.rs index 4b299ce9..4636f139 100644 --- a/src/api/api_keys.rs +++ b/src/api/api_keys.rs @@ -329,6 +329,16 @@ mod tests { test_api_key_no_access(server, StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED).await; } + #[sqlx::test(fixtures( + path = "../fixtures", + scripts("organizations", "api_users", "projects", "api_keys") + ))] + async fn test_api_key_no_access_blocked_user(pool: PgPool) { + let blocked_user = "b0c918e3-8183-430f-83eb-78b182ebef9e".parse().unwrap(); // blocked admin of org 1 + let server = TestServer::new(pool.clone(), Some(blocked_user)).await; + test_api_key_no_access(server, StatusCode::FORBIDDEN, StatusCode::FORBIDDEN).await; + } + impl TestServer { pub async fn use_api_key(&mut self, org_id: OrganizationId, role: Role) -> ApiKeyId { // request an API key using the currently logged-in user diff --git a/src/api/api_users.rs b/src/api/api_users.rs index 470f69b7..89931f1d 100644 --- a/src/api/api_users.rs +++ b/src/api/api_users.rs @@ -39,10 +39,11 @@ fn has_read_access(user_id: ApiUserId, user: &ApiUser) -> Result<(), AppError> { } fn has_write_access(user_id: ApiUserId, user: &ApiUser) -> Result<(), AppError> { - if *user.id() == user_id { - return Ok(()); + if *user.id() != user_id || user.blocked { + Err(AppError::Forbidden) + } else { + Ok(()) } - Err(AppError::Forbidden) } /// Get all API users @@ -631,6 +632,21 @@ mod tests { .await; } + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] + async fn test_update_user_no_access_blocked(pool: PgPool) { + let repo = ApiUserRepository::new(pool.clone()); + let user_3 = "54432300-128a-46a0-8a83-fe39ce3ce5ef".parse().unwrap(); + repo.update_block_status(&user_3, true).await.unwrap(); + test_update_user_no_access( + pool, + Some(user_3), + "unsecure123", + StatusCode::FORBIDDEN, + StatusCode::FORBIDDEN, + ) + .await; + } + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] async fn test_update_user_no_access_wrong_password(pool: PgPool) { test_update_user_no_access( @@ -872,7 +888,7 @@ mod tests { let res = server.get("/api/api_user").await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let users: Vec = deserialize_body(res.into_body()).await; - assert_eq!(users.len(), 11); + assert_eq!(users.len(), 12); let user1 = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); server.set_user(Some(user1)); diff --git a/src/api/auth.rs b/src/api/auth.rs index ca50af1d..8f34f867 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -86,19 +86,18 @@ pub trait Authenticated: Send + Sync { impl ApiUser { /// Check if user is super admin (has access to all organizations) pub fn is_super_admin(&self) -> bool { - self.global_role - .as_ref() - .is_some_and(|role| *role == Role::Admin) + !self.blocked && self.global_role.is_some_and(|role| role == Role::Admin) } } impl Authenticated for ApiUser { fn is_at_least(&self, org_id: &OrganizationId, role: Role) -> bool { - self.is_super_admin() - || self - .org_roles - .iter() - .any(|org_role| org_role.org_id == *org_id && org_role.role.is_at_least(role)) + !self.blocked + && (self.is_super_admin() + || self + .org_roles + .iter() + .any(|org_role| org_role.org_id == *org_id && org_role.role.is_at_least(role))) } fn viewable_organizations_filter(&self) -> Option> { @@ -271,6 +270,13 @@ pub(super) async fn password_login( .find_by_email(&login_attempt.email) .await? .ok_or(AppError::NotFound)?; + if user.blocked { + tracing::info!( + user_id = user.id().to_string(), + "Blocked user attempted to log in via password" + ); + return Err(AppError::Forbidden); + } let whoami; if repo.mfa_enabled(user.id()).await? { @@ -760,6 +766,24 @@ pub mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] + async fn test_cannot_login_when_blocked(pool: PgPool) { + let server = TestServer::new(pool.clone(), None).await; + + // try to login with a blocked user + let response = server + .post( + "/api/login/password", + serialize_body(json!({ + "email": "test-api@blocked-user", + "password": "unsecure123" + })), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] async fn test_cannot_register_when_account_creation_disabled(pool: PgPool) { let config_repo = RuntimeConfigRepository::new(pool.clone()); diff --git a/src/api/invites.rs b/src/api/invites.rs index a0ecfec3..f226c66a 100644 --- a/src/api/invites.rs +++ b/src/api/invites.rs @@ -432,6 +432,13 @@ mod tests { .await; } + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users", "invites")))] + async fn test_invites_no_access_blocked_user(pool: PgPool) { + let blocked_user = "b0c918e3-8183-430f-83eb-78b182ebef9e".parse().unwrap(); // blocked admin of org 1 + let mut server = TestServer::new(pool, Some(blocked_user)).await; + test_invites_no_access(&mut server, StatusCode::FORBIDDEN, StatusCode::FORBIDDEN).await; + } + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users", "invites")))] async fn test_cannot_use_removed_invite(pool: PgPool) { let user_1 = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); // is admin of org 1 and 2 diff --git a/src/api/messages.rs b/src/api/messages.rs index efa4b49b..dfb1b939 100644 --- a/src/api/messages.rs +++ b/src/api/messages.rs @@ -770,6 +770,22 @@ mod tests { test_messages_no_access(server, StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED).await; } + #[sqlx::test(fixtures( + path = "../fixtures", + scripts( + "organizations", + "api_users", + "projects", + "smtp_credentials", + "messages" + ) + ))] + async fn test_messages_no_access_blocked_user(pool: PgPool) { + let blocked_user = "b0c918e3-8183-430f-83eb-78b182ebef9e".parse().unwrap(); // blocked admin of org 1 + let server = TestServer::new(pool.clone(), Some(blocked_user)).await; + test_messages_no_access(server, StatusCode::FORBIDDEN, StatusCode::FORBIDDEN).await; + } + #[sqlx::test(fixtures( path = "../fixtures", scripts( diff --git a/src/api/oauth/handlers.rs b/src/api/oauth/handlers.rs index 283ae7c1..b0d28db0 100644 --- a/src/api/oauth/handlers.rs +++ b/src/api/oauth/handlers.rs @@ -169,6 +169,13 @@ where let api_user = service .fetch_user(token.access_token(), logged_in_api_user) .await?; + if api_user.blocked { + tracing::info!( + user_id = api_user.id().to_string(), + "Blocked user attempted to log in via oauth" + ); + return Err(Error::Forbidden); + } cookie_storage = login(&api_user, LoginState::LoggedIn, cookie_storage)?; @@ -180,7 +187,6 @@ where set_cookie_attributes(&mut redirect_cookie); let redirect = redirect_cookie.value().to_owned(); cookie_storage = cookie_storage.remove(redirect_cookie); - tracing::info!("Redirecting to {}", redirect); Ok((cookie_storage, Redirect::to(&redirect)).into_response()) } else { Ok((cookie_storage, Redirect::to("/")).into_response()) diff --git a/src/api/organizations.rs b/src/api/organizations.rs index ffd970b1..bf5a38f0 100644 --- a/src/api/organizations.rs +++ b/src/api/organizations.rs @@ -106,7 +106,7 @@ pub async fn create_organization( user: ApiUser, // only users are allowed to create organizations ValidatedJson(new): ValidatedJson, ) -> Result { - if !config_repo.account_creation_is_enabled().await? { + if !config_repo.account_creation_is_enabled().await? || user.blocked { return Err(AppError::Forbidden); } @@ -714,6 +714,7 @@ mod tests { let user_1 = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); // is admin of org 1 and 2 let user_2 = "94a98d6f-1ec0-49d2-a951-92dc0ff3042a".parse().unwrap(); // is admin of org 2 let user_5 = "703bf1cb-7a3e-4640-83bf-1b07ce18cd2e"; // is read-only in org 1 + let blocked_admin = "b0c918e3-8183-430f-83eb-78b182ebef9e"; // is blocked admin in org 1 let org_1 = "44729d9f-a7dc-4226-b412-36a7537f5176"; let org_2 = "5d55aec5-136a-407c-952f-5348d4398204"; let mut server = TestServer::new(pool.clone(), Some(user_1)).await; @@ -725,6 +726,15 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::OK); + // remove blocked admin from org 1 + let response = server + .delete(format!( + "/api/organizations/{org_1}/members/{blocked_admin}" + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + // org 1 now has 2 remaining members: an admin and a maintainer let response = server .get(format!("/api/organizations/{org_1}/members")) @@ -869,4 +879,21 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } + + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] + async fn cannot_create_organizations_when_blocked(pool: PgPool) { + let blocked_user = "b0c918e3-8183-430f-83eb-78b182ebef9e".parse().unwrap(); + let server = TestServer::new(pool, Some(blocked_user)).await; + + let response = server + .post( + "/api/organizations", + serialize_body(&NewOrganization { + name: "Test Org".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } } diff --git a/src/fixtures/api_users.sql b/src/fixtures/api_users.sql index f668541c..d143f571 100644 --- a/src/fixtures/api_users.sql +++ b/src/fixtures/api_users.sql @@ -57,3 +57,11 @@ INSERT INTO api_users (id, email, name) -- Read-only member of the "First subscr VALUES ('9aa7656f-6f52-4a4c-b7cf-93600e613177', 'subscription2@test.com', 'subscription2 test'); INSERT INTO api_users_organizations (api_user_id, organization_id, role) VALUES ('9aa7656f-6f52-4a4c-b7cf-93600e613177', 'e11df9da-56f5-433c-9d3a-dd338f262c66', 'read_only'); + + +INSERT INTO api_users (id, email, name, password_hash, blocked) -- blocked-user: admin in org 1, password is unsecure123 +VALUES ('b0c918e3-8183-430f-83eb-78b182ebef9e', 'test-api@blocked-user', 'Blocked User', + '$argon2id$v=19$m=16,t=2,p=1$VlZ0SUUzdXRBZVFldEZpbQ$rNhHR3o94Zw1B4YVqty6xQ', true); + +INSERT INTO api_users_organizations (api_user_id, organization_id, role) +VALUES ('b0c918e3-8183-430f-83eb-78b182ebef9e', '44729d9f-a7dc-4226-b412-36a7537f5176', 'admin'); diff --git a/src/models/api_user.rs b/src/models/api_user.rs index d0f17e5d..3350d8d0 100644 --- a/src/models/api_user.rs +++ b/src/models/api_user.rs @@ -141,6 +141,7 @@ pub struct ApiUser { pub org_roles: Vec, pub github_user_id: Option, pub password_enabled: bool, + pub blocked: bool, pub updated_at: DateTime, pub created_at: DateTime, } @@ -235,6 +236,7 @@ struct PgApiUser { global_role: Option, github_user_id: Option, password_enabled: bool, + blocked: bool, updated_at: DateTime, created_at: DateTime, } @@ -256,6 +258,7 @@ impl TryFrom for ApiUser { org_roles, github_user_id: u.github_user_id, password_enabled: u.password_enabled, + blocked: u.blocked, updated_at: u.updated_at, created_at: u.created_at, }) @@ -535,6 +538,7 @@ impl ApiUserRepository { array_agg((o.organization_id,o.role)::org_role)::org_role[] AS "organization_roles!: Vec", u.global_role AS "global_role: Role", u.password_hash IS NOT NULL AS "password_enabled!", + u.blocked, u.updated_at, u.created_at FROM api_users u @@ -843,7 +847,6 @@ impl ApiUserRepository { Ok(()) } - #[cfg_attr(test, allow(dead_code))] pub async fn find_by_id(&self, id: &ApiUserId) -> Result, Error> { sqlx::query_as!( PgApiUser, @@ -855,6 +858,7 @@ impl ApiUserRepository { array_agg((o.organization_id,o.role)::org_role)::org_role[] AS "organization_roles!: Vec", u.global_role AS "global_role: Role", u.password_hash IS NOT NULL AS "password_enabled!", + u.blocked, u.updated_at, u.created_at FROM api_users u @@ -881,6 +885,7 @@ impl ApiUserRepository { array_agg((o.organization_id,o.role)::org_role)::org_role[] AS "organization_roles!: Vec", u.global_role AS "global_role: Role", u.password_hash IS NOT NULL AS "password_enabled!", + u.blocked, u.updated_at, u.created_at FROM api_users u @@ -907,6 +912,7 @@ impl ApiUserRepository { array_agg((o.organization_id,o.role)::org_role)::org_role[] AS "organization_roles!: Vec", u.global_role AS "global_role: Role", u.password_hash IS NOT NULL AS "password_enabled!", + u.blocked, u.updated_at, u.created_at FROM api_users u @@ -974,6 +980,23 @@ impl ApiUserRepository { } Err(Error::NotFound("User not found or wrong password")) } + + pub async fn update_block_status(&self, id: &ApiUserId, blocked: bool) -> Result<(), Error> { + sqlx::query_as!( + PgApiUser, + r#" + UPDATE api_users + SET blocked = $2 + WHERE id = $1 + "#, + **id, + blocked + ) + .execute(&self.pool) + .await?; + + Ok(()) + } } #[cfg(test)] @@ -991,6 +1014,7 @@ mod test { org_roles, github_user_id: None, password_enabled: false, + blocked: false, updated_at: Utc::now(), created_at: Utc::now(), } @@ -1029,6 +1053,7 @@ mod test { #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations")))] async fn create_user(db: PgPool) { let repo = ApiUserRepository::new(db); + let users = repo.get_all().await.unwrap().len(); let user = NewApiUser { email: "test@email.com".parse().unwrap(), @@ -1041,10 +1066,9 @@ mod test { }], github_user_id: Some(123), }; - let created = repo.create(user.clone()).await.unwrap(); - assert_eq!(created, user); + assert_eq!(repo.get_all().await.unwrap().len(), users + 1); let user = NewApiUser { email: "test2@email.com".parse().unwrap(), @@ -1054,17 +1078,8 @@ mod test { org_roles: vec![], github_user_id: None, }; - let created = repo.create(user.clone()).await.unwrap(); - assert_eq!(created, user); - } - - #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] - async fn get_all(db: PgPool) { - let repo = ApiUserRepository::new(db); - - let users = repo.get_all().await.unwrap(); - assert_eq!(users.len(), 11); + assert_eq!(repo.get_all().await.unwrap().len(), users + 2); } } From adfe7bfa7c83125ed03a71192973060944adcea4 Mon Sep 17 00:00:00 2001 From: Michiel Date: Wed, 18 Mar 2026 12:28:16 +0100 Subject: [PATCH 2/9] Prepare sqlx --- ...c8a50d1b5d408cdae2f88f795ca283f926ff40a3.json} | 12 +++++++++--- ...04a85e5e5bcc8a93005868d133327ae27d68ece8.json} | 12 +++++++++--- ...dba52cb5f94457ca711e408570720dc03a45cdb8.json} | 12 +++++++++--- ...a9c4dda9d0f79b12ca0e07a925054aa2a2577371.json} | 12 +++++++++--- ...9882a1d11ada807a4eef46efd5ba69ed5b459bbb5.json | 15 +++++++++++++++ 5 files changed, 51 insertions(+), 12 deletions(-) rename .sqlx/{query-5ee620ac3c21d2f897160ccbb1401c7b77c4d1f8f9af1be14ffa2653fafd20aa.json => query-0531daf7468e9201fabcc22bc8a50d1b5d408cdae2f88f795ca283f926ff40a3.json} (85%) rename .sqlx/{query-822377b67cb5d283317868daf6473ab8c71bfdcb4b73774a28789ee13fcc0dc1.json => query-0ba37b2f107a269ddbdcf66f04a85e5e5bcc8a93005868d133327ae27d68ece8.json} (85%) rename .sqlx/{query-ba7acaef70e8020dbc993ccc1e31f883512311dfd8cc7e8429a102a17bbc0625.json => query-10621142ecab6bd0495d00f5dba52cb5f94457ca711e408570720dc03a45cdb8.json} (85%) rename .sqlx/{query-51f71ecd188bce3735f351c4295139955575fb4f17913c8203bde81a9ac8d2ed.json => query-3773efed49a9a3471fb8bfc2a9c4dda9d0f79b12ca0e07a925054aa2a2577371.json} (85%) create mode 100644 .sqlx/query-b951bbccf8832566db59fbb9882a1d11ada807a4eef46efd5ba69ed5b459bbb5.json diff --git a/.sqlx/query-5ee620ac3c21d2f897160ccbb1401c7b77c4d1f8f9af1be14ffa2653fafd20aa.json b/.sqlx/query-0531daf7468e9201fabcc22bc8a50d1b5d408cdae2f88f795ca283f926ff40a3.json similarity index 85% rename from .sqlx/query-5ee620ac3c21d2f897160ccbb1401c7b77c4d1f8f9af1be14ffa2653fafd20aa.json rename to .sqlx/query-0531daf7468e9201fabcc22bc8a50d1b5d408cdae2f88f795ca283f926ff40a3.json index 0c1142ea..ace1128f 100644 --- a/.sqlx/query-5ee620ac3c21d2f897160ccbb1401c7b77c4d1f8f9af1be14ffa2653fafd20aa.json +++ b/.sqlx/query-0531daf7468e9201fabcc22bc8a50d1b5d408cdae2f88f795ca283f926ff40a3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n WHERE github_user_id = $1\n GROUP BY u.id\n ", + "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n WHERE github_user_id = $1\n GROUP BY u.id\n ", "describe": { "columns": [ { @@ -85,11 +85,16 @@ }, { "ordinal": 7, + "name": "blocked", + "type_info": "Bool" + }, + { + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" } @@ -108,8 +113,9 @@ true, null, false, + false, false ] }, - "hash": "5ee620ac3c21d2f897160ccbb1401c7b77c4d1f8f9af1be14ffa2653fafd20aa" + "hash": "0531daf7468e9201fabcc22bc8a50d1b5d408cdae2f88f795ca283f926ff40a3" } diff --git a/.sqlx/query-822377b67cb5d283317868daf6473ab8c71bfdcb4b73774a28789ee13fcc0dc1.json b/.sqlx/query-0ba37b2f107a269ddbdcf66f04a85e5e5bcc8a93005868d133327ae27d68ece8.json similarity index 85% rename from .sqlx/query-822377b67cb5d283317868daf6473ab8c71bfdcb4b73774a28789ee13fcc0dc1.json rename to .sqlx/query-0ba37b2f107a269ddbdcf66f04a85e5e5bcc8a93005868d133327ae27d68ece8.json index 604a1a13..420b1ed4 100644 --- a/.sqlx/query-822377b67cb5d283317868daf6473ab8c71bfdcb4b73774a28789ee13fcc0dc1.json +++ b/.sqlx/query-0ba37b2f107a269ddbdcf66f04a85e5e5bcc8a93005868d133327ae27d68ece8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n WHERE u.email = $1\n GROUP BY u.id\n ", + "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n WHERE u.email = $1\n GROUP BY u.id\n ", "describe": { "columns": [ { @@ -85,11 +85,16 @@ }, { "ordinal": 7, + "name": "blocked", + "type_info": "Bool" + }, + { + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" } @@ -108,8 +113,9 @@ true, null, false, + false, false ] }, - "hash": "822377b67cb5d283317868daf6473ab8c71bfdcb4b73774a28789ee13fcc0dc1" + "hash": "0ba37b2f107a269ddbdcf66f04a85e5e5bcc8a93005868d133327ae27d68ece8" } diff --git a/.sqlx/query-ba7acaef70e8020dbc993ccc1e31f883512311dfd8cc7e8429a102a17bbc0625.json b/.sqlx/query-10621142ecab6bd0495d00f5dba52cb5f94457ca711e408570720dc03a45cdb8.json similarity index 85% rename from .sqlx/query-ba7acaef70e8020dbc993ccc1e31f883512311dfd8cc7e8429a102a17bbc0625.json rename to .sqlx/query-10621142ecab6bd0495d00f5dba52cb5f94457ca711e408570720dc03a45cdb8.json index 39eda70a..8292b8c8 100644 --- a/.sqlx/query-ba7acaef70e8020dbc993ccc1e31f883512311dfd8cc7e8429a102a17bbc0625.json +++ b/.sqlx/query-10621142ecab6bd0495d00f5dba52cb5f94457ca711e408570720dc03a45cdb8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n WHERE u.id = $1\n GROUP BY u.id\n ", + "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n WHERE u.id = $1\n GROUP BY u.id\n ", "describe": { "columns": [ { @@ -85,11 +85,16 @@ }, { "ordinal": 7, + "name": "blocked", + "type_info": "Bool" + }, + { + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" } @@ -108,8 +113,9 @@ true, null, false, + false, false ] }, - "hash": "ba7acaef70e8020dbc993ccc1e31f883512311dfd8cc7e8429a102a17bbc0625" + "hash": "10621142ecab6bd0495d00f5dba52cb5f94457ca711e408570720dc03a45cdb8" } diff --git a/.sqlx/query-51f71ecd188bce3735f351c4295139955575fb4f17913c8203bde81a9ac8d2ed.json b/.sqlx/query-3773efed49a9a3471fb8bfc2a9c4dda9d0f79b12ca0e07a925054aa2a2577371.json similarity index 85% rename from .sqlx/query-51f71ecd188bce3735f351c4295139955575fb4f17913c8203bde81a9ac8d2ed.json rename to .sqlx/query-3773efed49a9a3471fb8bfc2a9c4dda9d0f79b12ca0e07a925054aa2a2577371.json index 8f93fe0a..2ea8c9a3 100644 --- a/.sqlx/query-51f71ecd188bce3735f351c4295139955575fb4f17913c8203bde81a9ac8d2ed.json +++ b/.sqlx/query-3773efed49a9a3471fb8bfc2a9c4dda9d0f79b12ca0e07a925054aa2a2577371.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n GROUP BY u.id\n ORDER BY u.updated_at DESC\n ", + "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n GROUP BY u.id\n ORDER BY u.updated_at DESC\n ", "describe": { "columns": [ { @@ -85,11 +85,16 @@ }, { "ordinal": 7, + "name": "blocked", + "type_info": "Bool" + }, + { + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" } @@ -106,8 +111,9 @@ true, null, false, + false, false ] }, - "hash": "51f71ecd188bce3735f351c4295139955575fb4f17913c8203bde81a9ac8d2ed" + "hash": "3773efed49a9a3471fb8bfc2a9c4dda9d0f79b12ca0e07a925054aa2a2577371" } diff --git a/.sqlx/query-b951bbccf8832566db59fbb9882a1d11ada807a4eef46efd5ba69ed5b459bbb5.json b/.sqlx/query-b951bbccf8832566db59fbb9882a1d11ada807a4eef46efd5ba69ed5b459bbb5.json new file mode 100644 index 00000000..41adcc89 --- /dev/null +++ b/.sqlx/query-b951bbccf8832566db59fbb9882a1d11ada807a4eef46efd5ba69ed5b459bbb5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_users\n SET blocked = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "b951bbccf8832566db59fbb9882a1d11ada807a4eef46efd5ba69ed5b459bbb5" +} From a2bd0077e13c443783d3151e7286d1e7e0b84bb4 Mon Sep 17 00:00:00 2001 From: Michiel Date: Wed, 18 Mar 2026 16:58:39 +0100 Subject: [PATCH 3/9] Update admin interface to support blocking users --- .../src/components/admin/ApiUserOverview.tsx | 59 ++++++--- .../src/components/admin/ManageApiUser.tsx | 114 +++++++++++++++++ frontend/src/components/admin/RoleSelect.tsx | 50 -------- .../src/components/organizations/Members.tsx | 2 +- frontend/src/reducer.ts | 4 +- frontend/src/types.ts | 5 +- src/api/api_users.rs | 120 ++++++++++++++---- src/api/whoami.rs | 2 + src/models/api_user.rs | 48 +++---- 9 files changed, 281 insertions(+), 123 deletions(-) create mode 100644 frontend/src/components/admin/ManageApiUser.tsx delete mode 100644 frontend/src/components/admin/RoleSelect.tsx diff --git a/frontend/src/components/admin/ApiUserOverview.tsx b/frontend/src/components/admin/ApiUserOverview.tsx index ed4f516a..4c60753c 100644 --- a/frontend/src/components/admin/ApiUserOverview.tsx +++ b/frontend/src/components/admin/ApiUserOverview.tsx @@ -1,16 +1,20 @@ import { useApiUsers } from "../../hooks/useApiUsers.ts"; import StyledTable from "../StyledTable.tsx"; -import { Anchor, Flex, Group, Pagination, Table } from "@mantine/core"; +import { Badge, Button, Flex, Group, Pagination, Table } from "@mantine/core"; import TableId from "../TableId.tsx"; -import RoleSelect from "./RoleSelect.tsx"; import OrgPopover from "./OrgPopover.tsx"; import { formatDateTime } from "../../util.ts"; import { useState } from "react"; -import { useScrollIntoView } from "@mantine/hooks"; +import { useDisclosure, useScrollIntoView } from "@mantine/hooks"; +import { IconEdit } from "@tabler/icons-react"; +import ManageApiUser from "./ManageApiUser.tsx"; +import { User } from "../../types.ts"; const PER_PAGE = 20; export default function ApiUserOverview() { + const [opened, { open, close }] = useDisclosure(false); + const [managingUser, setManagingUser] = useState(null); const [activePage, setPage] = useState(1); const { apiUsers } = useApiUsers(); @@ -21,32 +25,55 @@ export default function ApiUserOverview() { const rows = apiUsers.slice((activePage - 1) * PER_PAGE, activePage * PER_PAGE).map((user) => ( - + - {user.name} - - {user.email} - - - - + + {user.name} + + {user.email} + + {user.global_role == "admin" && ( + + Admin + + )} + {user.blocked && ( + + Blocked + + )} + - + {user.org_roles.length.toString()} - {formatDateTime(user.created_at)} - {formatDateTime(user.updated_at)} - + {formatDateTime(user.updated_at)} + {formatDateTime(user.created_at)} + + + + )); return (
- + + {rows} diff --git a/frontend/src/components/admin/ManageApiUser.tsx b/frontend/src/components/admin/ManageApiUser.tsx new file mode 100644 index 00000000..14867910 --- /dev/null +++ b/frontend/src/components/admin/ManageApiUser.tsx @@ -0,0 +1,114 @@ +import { Button, Checkbox, Group, Modal, Select, Stack, Title } from "@mantine/core"; +import { Role, User } from "../../types.ts"; +import { useRemails } from "../../hooks/useRemails.ts"; +import { errorNotification } from "../../notify.tsx"; +import { useForm } from "@mantine/form"; +import { IconTrash } from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; +import { AdminButton } from "../RoleButtons.tsx"; + +interface ManageApiUserProps { + opened: boolean; + close: () => void; + user: User | null; +} + +interface FormValues { + global_role: Role | null; + blocked: boolean; +} + +export default function ManageApiUser({ opened, close, user }: ManageApiUserProps) { + const { dispatch } = useRemails(); + const { + state: { user: me }, + } = useRemails(); + + const form = useForm({ + initialValues: { + global_role: user?.global_role || null, + blocked: user?.blocked || false, + } + }); + + if (!user) { + return null; + } + + const save = async (values: FormValues) => { + const res = await fetch(`/api/api_user/${user.id}/manage`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + if (res.ok) { + dispatch({ type: "update_api_user", user_id: user.id, user: { ...user, ...values } }); + notifications.show({ + title: "User updated", + message: "", + color: "green", + }); + form.resetDirty(); + } else { + errorNotification("Could not update user"); + console.error(res); + } + }; + + return ( + + Manage user {user.name} + + } + size="lg" + padding="xl" + onExitTransitionEnd={form.reset} + > +
+ + { - await updateRole(value as Role); - }} - /> - ); -} diff --git a/frontend/src/components/organizations/Members.tsx b/frontend/src/components/organizations/Members.tsx index e0446037..fe8c9c8e 100644 --- a/frontend/src/components/organizations/Members.tsx +++ b/frontend/src/components/organizations/Members.tsx @@ -186,7 +186,7 @@ export default function Members() { const updateRole = (member: OrganizationMember) => { modals.open({ - title: `Edit role of ${member.name}`, + title: Edit role of {member.name}, children: ( modals.closeAll()} diff --git a/frontend/src/reducer.ts b/frontend/src/reducer.ts index 835c3fc0..130cddbd 100644 --- a/frontend/src/reducer.ts +++ b/frontend/src/reducer.ts @@ -9,13 +9,13 @@ const actionHandler: { set_api_users: function (state, action) { return { ...state, apiUsers: action.users }; }, - set_api_user_role: function (state, action) { + update_api_user: function (state, action) { return { ...state, apiUsers: state.apiUsers?.map((u) => { if (u.id === action.user_id) { - u.global_role = action.role; + return action.user; } return u; }) || [], diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5c9f45f2..0953c902 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -12,6 +12,7 @@ export interface User { email: string; github_id: string | null; password_enabled: boolean; + blocked: boolean; created_at: string; updated_at: string; } @@ -136,9 +137,9 @@ export type Action = users: User[] | null; } | { - type: "set_api_user_role"; + type: "update_api_user"; user_id: string; - role: Role | null; + user: User; } | { type: "set_totp_codes"; diff --git a/src/api/api_users.rs b/src/api/api_users.rs index 89931f1d..b99dad36 100644 --- a/src/api/api_users.rs +++ b/src/api/api_users.rs @@ -6,8 +6,9 @@ use crate::{ whoami::{Whoami, WhoamiResponse}, }, models::{ - ApiUser, ApiUserId, ApiUserRepository, ApiUserUpdate, Error, Password, PasswordUpdate, - PwResetId, ResetLinkCheck, Role, TotpCode, TotpCodeDetails, TotpFinishEnroll, TotpId, + ApiUser, ApiUserId, ApiUserRepository, ApiUserUpdate, Error, ManageApiUser, Password, + PasswordUpdate, PwResetId, ResetLinkCheck, TotpCode, TotpCodeDetails, TotpFinishEnroll, + TotpId, }, }; use axum::{ @@ -24,9 +25,8 @@ use utoipa_axum::{router::OpenApiRouter, routes}; pub fn router() -> OpenApiRouter { OpenApiRouter::new() - .routes(routes!(update_user)) + .routes(routes!(update_user, manage_user)) .routes(routes!(get_all)) - .routes(routes!(set_global_role)) .routes(routes!(is_password_reset_active)) .routes(routes!(password_reset)) .routes(routes!(update_password, delete_password)) @@ -73,32 +73,32 @@ pub async fn get_all( } /// Set the global role of an API user -#[utoipa::path(put, path = "/api_user/{user_id}/role", +#[utoipa::path(put, path = "/api_user/{user_id}/manage", tags = ["internal", "API users"], - request_body = Role, + request_body = ManageApiUser, security(("cookieAuth" = [])), responses( - (status = 200, description = "Successfully updated user role"), + (status = 200, description = "Successfully updated user"), AppError, ))] -pub async fn set_global_role( +pub async fn manage_user( State(repo): State, Path((user_id,)): Path<(ApiUserId,)>, user: ApiUser, - Json(role): Json>, + Json(settings): Json, ) -> Result<(), AppError> { if !user.is_super_admin() { return Err(AppError::Forbidden); } - let old_role = repo.set_global_role(user_id, role).await?; + repo.manage(user_id, &settings).await?; info!( user_id = user_id.to_string(), executing_user_id = user.id().to_string(), - ?old_role, - new_role = ?role, - "updated global user role" + new_role = ?settings.global_role, + blocked = settings.blocked, + "admin updated user" ); Ok(()) @@ -403,10 +403,14 @@ pub async fn delete_password( #[cfg(test)] mod tests { use super::*; - use crate::api::{ - auth::tests::get_session_cookie, - tests::{TestServer, deserialize_body, serialize_body}, - whoami::Whoami, + use crate::{ + api::{ + auth::tests::get_session_cookie, + tests::{TestServer, deserialize_body, serialize_body}, + whoami::Whoami, + }, + models::Role, + test::TestProjects, }; use http::StatusCode; use mail_parser::MessageParser; @@ -636,7 +640,15 @@ mod tests { async fn test_update_user_no_access_blocked(pool: PgPool) { let repo = ApiUserRepository::new(pool.clone()); let user_3 = "54432300-128a-46a0-8a83-fe39ce3ce5ef".parse().unwrap(); - repo.update_block_status(&user_3, true).await.unwrap(); + repo.manage( + user_3, + &ManageApiUser { + global_role: None, + blocked: true, + }, + ) + .await + .unwrap(); test_update_user_no_access( pool, Some(user_3), @@ -909,8 +921,11 @@ mod tests { let res = server .put( - format!("/api/api_user/{}/role", user1), - serialize_body(json!("admin")), + format!("/api/api_user/{user1}/manage"), + serialize_body(ManageApiUser { + global_role: Some(Role::Admin), + blocked: false, + }), ) .await .unwrap(); @@ -933,8 +948,11 @@ mod tests { // Remove global role again let res = server .put( - format!("/api/api_user/{}/role", user1), - serialize_body(json!(None::)), + format!("/api/api_user/{user1}/manage"), + serialize_body(ManageApiUser { + global_role: None, + blocked: false, + }), ) .await .unwrap(); @@ -949,11 +967,65 @@ mod tests { server.set_user(Some(user1)); let res = server .put( - format!("/api/api_user/{}/role", user1), - serialize_body(json!("admin")), + format!("/api/api_user/{user1}/manage"), + serialize_body(ManageApiUser { + global_role: Some(Role::Admin), + blocked: false, + }), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::FORBIDDEN); } + + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] + async fn test_blocking_users(pool: PgPool) { + let admin: ApiUserId = "deadbeef-4e43-4a66-bbb9-fbcd4a933a34".parse().unwrap(); + let user1: ApiUserId = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); + let org_1 = TestProjects::Org1Project1.org_id(); + + let mut server = TestServer::new(pool.clone(), Some(admin)).await; + + let res = server + .put( + format!("/api/api_user/{user1}/manage"), + serialize_body(ManageApiUser { + global_role: None, + blocked: true, + }), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + // Check user1 actually got blocked + server.set_user(Some(user1)); + let res = server + .get(format!("/api/organizations/{org_1}/projects")) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + + // Unblock user1 again + server.set_user(Some(admin)); + let res = server + .put( + format!("/api/api_user/{user1}/manage"), + serialize_body(ManageApiUser { + global_role: None, + blocked: false, + }), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + // Check user1 is unblocked + server.set_user(Some(user1)); + let res = server + .get(format!("/api/organizations/{org_1}/projects")) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + } } diff --git a/src/api/whoami.rs b/src/api/whoami.rs index fba0ce10..550b2418 100644 --- a/src/api/whoami.rs +++ b/src/api/whoami.rs @@ -45,6 +45,7 @@ pub struct Whoami { /// Unlike in `ApiUser`, here the GitHub ID is a string pub github_id: Option, pub password_enabled: bool, + pub blocked: bool, pub created_at: DateTime, pub updated_at: DateTime, } @@ -59,6 +60,7 @@ impl From for Whoami { org_roles: user.org_roles, name: user.name, email: user.email, + blocked: user.blocked, created_at: user.created_at, updated_at: user.updated_at, } diff --git a/src/models/api_user.rs b/src/models/api_user.rs index 3350d8d0..31db848e 100644 --- a/src/models/api_user.rs +++ b/src/models/api_user.rs @@ -155,6 +155,15 @@ pub struct ApiUserUpdate { pub email: EmailAddress, } +#[derive(Debug, Deserialize, ToSchema, Validate)] +#[cfg_attr(test, derive(Serialize))] +pub struct ManageApiUser { + #[garde(skip)] + pub global_role: Option, + #[garde(skip)] + pub blocked: bool, +} + #[derive(Debug, Deserialize, ToSchema, Validate)] pub struct PasswordUpdate { #[garde(dive)] @@ -595,21 +604,21 @@ impl ApiUserRepository { Ok(()) } - pub async fn set_global_role( - &self, - id: ApiUserId, - role: Option, - ) -> Result, Error> { - let old_role: Option = sqlx::query_scalar!( + pub async fn manage(&self, id: ApiUserId, settings: &ManageApiUser) -> Result<(), Error> { + sqlx::query_scalar!( r#" - UPDATE api_users SET global_role = $2 WHERE id = $1 RETURNING OLD.global_role AS "r:Role" + UPDATE api_users + SET global_role = $2, blocked = $3 + WHERE id = $1 "#, *id, - role as _ + settings.global_role as _, + settings.blocked ) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(old_role) + + Ok(()) } pub async fn update_password( @@ -963,7 +972,7 @@ impl ApiUserRepository { { if counter > 3 { // TODO, we might want to send an email to the user telling their account got temporarily blocked (see #222) - // Note, we must not show any other behaviour to the outside world to avoid leaking if an account exists + // Note, we must not show any other behavior to the outside world to avoid leaking if an account exists tracing::warn!( attempts = counter, "Too many failed password attempts for user {}", @@ -980,23 +989,6 @@ impl ApiUserRepository { } Err(Error::NotFound("User not found or wrong password")) } - - pub async fn update_block_status(&self, id: &ApiUserId, blocked: bool) -> Result<(), Error> { - sqlx::query_as!( - PgApiUser, - r#" - UPDATE api_users - SET blocked = $2 - WHERE id = $1 - "#, - **id, - blocked - ) - .execute(&self.pool) - .await?; - - Ok(()) - } } #[cfg(test)] From c648f975ddfcd3ec8f79c13b34c2fcdd2dd8913e Mon Sep 17 00:00:00 2001 From: Michiel Date: Wed, 18 Mar 2026 18:31:56 +0100 Subject: [PATCH 4/9] Allow deleting API users from admin interface, fixes #117 --- .../src/components/admin/ManageApiUser.tsx | 51 ++++++++++++- frontend/src/reducer.ts | 14 +--- frontend/src/types.ts | 4 + src/api/api_users.rs | 76 ++++++++++++++++++- src/models/api_user.rs | 12 +++ 5 files changed, 143 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/admin/ManageApiUser.tsx b/frontend/src/components/admin/ManageApiUser.tsx index 14867910..73891fae 100644 --- a/frontend/src/components/admin/ManageApiUser.tsx +++ b/frontend/src/components/admin/ManageApiUser.tsx @@ -1,4 +1,4 @@ -import { Button, Checkbox, Group, Modal, Select, Stack, Title } from "@mantine/core"; +import { Button, Checkbox, Group, List, Modal, Select, Stack, Text, Title } from "@mantine/core"; import { Role, User } from "../../types.ts"; import { useRemails } from "../../hooks/useRemails.ts"; import { errorNotification } from "../../notify.tsx"; @@ -6,6 +6,9 @@ import { useForm } from "@mantine/form"; import { IconTrash } from "@tabler/icons-react"; import { notifications } from "@mantine/notifications"; import { AdminButton } from "../RoleButtons.tsx"; +import { modals } from "@mantine/modals"; +import { useOrganizations } from "../../hooks/useOrganizations.ts"; +import { ROLE_LABELS } from "../../util.ts"; interface ManageApiUserProps { opened: boolean; @@ -19,6 +22,7 @@ interface FormValues { } export default function ManageApiUser({ opened, close, user }: ManageApiUserProps) { + const { organizations } = useOrganizations(); const { dispatch } = useRemails(); const { state: { user: me }, @@ -35,6 +39,49 @@ export default function ManageApiUser({ opened, close, user }: ManageApiUserProp return null; } + const confirmDeleteUser = (user: User) => { + modals.openConfirmModal({ + title: `Are you sure you want to delete user ${user.name}?`, + centered: true, + children: ( + <> + + The user is in {user.org_roles.length} organization{user.org_roles.length === 1 ? "" : "s"}{user.org_roles.length > 0 ? ":" : "."} + + + {user.org_roles.map((org) => ( + + {organizations.find((o) => o.id === org.org_id)?.name || org.org_id} ({ROLE_LABELS[org.role]}) + + ))} + + + This action cannot be undone. + + + ), + labels: { confirm: "Delete", cancel: "Cancel" }, + confirmProps: { color: "red" }, + onConfirm: async () => { + const res = await fetch(`/api/api_user/${user.id}`, { + method: "DELETE", + }); + if (res.ok) { + dispatch({ type: "remove_api_user", user_id: user.id }); + notifications.show({ + title: "User deleted", + message: "", + color: "green", + }); + close(); + } else { + errorNotification("Could not delete user"); + console.error(res); + } + }, + }); + }; + const save = async (values: FormValues) => { const res = await fetch(`/api/api_user/${user.id}/manage`, { method: "PUT", @@ -93,7 +140,7 @@ export default function ManageApiUser({ opened, close, user }: ManageApiUserProp variant="outline" tooltip="Delete user" disabled={user.id === me?.id} - // TODO: onClick={() => confirmDeleteProject(currentProject)} + onClick={() => confirmDeleteUser(user)} > Delete diff --git a/frontend/src/reducer.ts b/frontend/src/reducer.ts index 130cddbd..c7c14be7 100644 --- a/frontend/src/reducer.ts +++ b/frontend/src/reducer.ts @@ -10,16 +10,10 @@ const actionHandler: { return { ...state, apiUsers: action.users }; }, update_api_user: function (state, action) { - return { - ...state, - apiUsers: - state.apiUsers?.map((u) => { - if (u.id === action.user_id) { - return action.user; - } - return u; - }) || [], - }; + return { ...state, apiUsers: state.apiUsers?.map((u) => u.id === action.user_id ? action.user : u) || [] }; + }, + remove_api_user: function (state, action) { + return { ...state, apiUsers: state.apiUsers?.filter((u) => u.id !== action.user_id) || [] }; }, add_organization: function (state, action) { return { ...state, organizations: [action.organization, ...(state.organizations || [])] }; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0953c902..71872584 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -141,6 +141,10 @@ export type Action = user_id: string; user: User; } + | { + type: "remove_api_user"; + user_id: string; + } | { type: "set_totp_codes"; totpCodes: TotpCode[] | null; diff --git a/src/api/api_users.rs b/src/api/api_users.rs index b99dad36..d50d3ce4 100644 --- a/src/api/api_users.rs +++ b/src/api/api_users.rs @@ -25,8 +25,9 @@ use utoipa_axum::{router::OpenApiRouter, routes}; pub fn router() -> OpenApiRouter { OpenApiRouter::new() - .routes(routes!(update_user, manage_user)) - .routes(routes!(get_all)) + .routes(routes!(update_user)) + .routes(routes!(manage_user)) + .routes(routes!(get_all, delete_user)) .routes(routes!(is_password_reset_active)) .routes(routes!(password_reset)) .routes(routes!(update_password, delete_password)) @@ -104,6 +105,34 @@ pub async fn manage_user( Ok(()) } +/// Delete an API user +#[utoipa::path(delete, path = "/api_user/{user_id}", + tags = ["internal", "API users"], + security(("cookieAuth" = [])), + responses( + (status = 200, description = "Successfully deleted user", body = ApiUserId), + AppError, +))] +pub async fn delete_user( + State(repo): State, + Path((user_id,)): Path<(ApiUserId,)>, + user: ApiUser, +) -> ApiResult { + if !user.is_super_admin() { + return Err(AppError::Forbidden); + } + + let deleted = repo.delete(user_id).await?; + + info!( + user_id = user_id.to_string(), + executing_user_id = user.id().to_string(), + "admin deleted user" + ); + + Ok(Json(deleted)) +} + /// Update API user details #[utoipa::path(put, path = "/api_user/{user_id}", tags = ["internal", "API users"], @@ -1028,4 +1057,47 @@ mod tests { .unwrap(); assert_eq!(res.status(), StatusCode::OK); } + + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] + async fn test_deleting_users(pool: PgPool) { + let admin: ApiUserId = "deadbeef-4e43-4a66-bbb9-fbcd4a933a34".parse().unwrap(); + let user1: ApiUserId = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); + let user2: ApiUserId = "94a98d6f-1ec0-49d2-a951-92dc0ff3042a".parse().unwrap(); + + // not logged in + let mut server = TestServer::new(pool.clone(), None).await; + let res = server + .delete(format!("/api/api_user/{user1}")) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + + // normal user + server.set_user(Some(user2)); + let res = server + .delete(format!("/api/api_user/{user1}")) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + + // Remails admin + server.set_user(Some(admin)); + + let res = server.get("/api/api_user").await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let users: Vec = deserialize_body(res.into_body()).await; + let num = users.len(); + + let res = server + .delete(format!("/api/api_user/{user1}")) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + // check user was deleted + let res = server.get("/api/api_user").await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let users: Vec = deserialize_body(res.into_body()).await; + assert_eq!(users.len(), num - 1); + } } diff --git a/src/models/api_user.rs b/src/models/api_user.rs index 31db848e..a223fdbb 100644 --- a/src/models/api_user.rs +++ b/src/models/api_user.rs @@ -621,6 +621,18 @@ impl ApiUserRepository { Ok(()) } + pub async fn delete(&self, id: ApiUserId) -> Result { + Ok(sqlx::query_scalar!( + r#" + DELETE FROM api_users WHERE id = $1 RETURNING id + "#, + *id + ) + .fetch_one(&self.pool) + .await? + .into()) + } + pub async fn update_password( &self, update: PasswordUpdate, From bbd7c29dec1443112bc96f35a3c45b67129474a0 Mon Sep 17 00:00:00 2001 From: Michiel Date: Wed, 18 Mar 2026 18:32:32 +0100 Subject: [PATCH 5/9] Prepare sqlx --- ...adf5165ccee11238ce79b9ee171687c6df7f5.json | 22 +++++++++ ...a1d11ada807a4eef46efd5ba69ed5b459bbb5.json | 15 ------- ...77218b445b9234f074294056eb4317b6d0a7a.json | 27 +++++++++++ ...283a1b1da03deb5378d6cfae9dbe2ba8218bd.json | 45 ------------------- 4 files changed, 49 insertions(+), 60 deletions(-) create mode 100644 .sqlx/query-9165b3e2d8662767d5a5465bb7fadf5165ccee11238ce79b9ee171687c6df7f5.json delete mode 100644 .sqlx/query-b951bbccf8832566db59fbb9882a1d11ada807a4eef46efd5ba69ed5b459bbb5.json create mode 100644 .sqlx/query-c126018f7e70786214fa341243777218b445b9234f074294056eb4317b6d0a7a.json delete mode 100644 .sqlx/query-f095f62c28c250b56f649aa52bc283a1b1da03deb5378d6cfae9dbe2ba8218bd.json diff --git a/.sqlx/query-9165b3e2d8662767d5a5465bb7fadf5165ccee11238ce79b9ee171687c6df7f5.json b/.sqlx/query-9165b3e2d8662767d5a5465bb7fadf5165ccee11238ce79b9ee171687c6df7f5.json new file mode 100644 index 00000000..0fb330f7 --- /dev/null +++ b/.sqlx/query-9165b3e2d8662767d5a5465bb7fadf5165ccee11238ce79b9ee171687c6df7f5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM api_users WHERE id = $1 RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9165b3e2d8662767d5a5465bb7fadf5165ccee11238ce79b9ee171687c6df7f5" +} diff --git a/.sqlx/query-b951bbccf8832566db59fbb9882a1d11ada807a4eef46efd5ba69ed5b459bbb5.json b/.sqlx/query-b951bbccf8832566db59fbb9882a1d11ada807a4eef46efd5ba69ed5b459bbb5.json deleted file mode 100644 index 41adcc89..00000000 --- a/.sqlx/query-b951bbccf8832566db59fbb9882a1d11ada807a4eef46efd5ba69ed5b459bbb5.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE api_users\n SET blocked = $2\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "b951bbccf8832566db59fbb9882a1d11ada807a4eef46efd5ba69ed5b459bbb5" -} diff --git a/.sqlx/query-c126018f7e70786214fa341243777218b445b9234f074294056eb4317b6d0a7a.json b/.sqlx/query-c126018f7e70786214fa341243777218b445b9234f074294056eb4317b6d0a7a.json new file mode 100644 index 00000000..2911d6ae --- /dev/null +++ b/.sqlx/query-c126018f7e70786214fa341243777218b445b9234f074294056eb4317b6d0a7a.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_users\n SET global_role = $2, blocked = $3\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "role", + "kind": { + "Enum": [ + "admin", + "maintainer", + "read_only" + ] + } + } + }, + "Bool" + ] + }, + "nullable": [] + }, + "hash": "c126018f7e70786214fa341243777218b445b9234f074294056eb4317b6d0a7a" +} diff --git a/.sqlx/query-f095f62c28c250b56f649aa52bc283a1b1da03deb5378d6cfae9dbe2ba8218bd.json b/.sqlx/query-f095f62c28c250b56f649aa52bc283a1b1da03deb5378d6cfae9dbe2ba8218bd.json deleted file mode 100644 index 5fd08ed6..00000000 --- a/.sqlx/query-f095f62c28c250b56f649aa52bc283a1b1da03deb5378d6cfae9dbe2ba8218bd.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE api_users SET global_role = $2 WHERE id = $1 RETURNING OLD.global_role AS \"r:Role\"\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "r:Role", - "type_info": { - "Custom": { - "name": "role", - "kind": { - "Enum": [ - "admin", - "maintainer", - "read_only" - ] - } - } - } - } - ], - "parameters": { - "Left": [ - "Uuid", - { - "Custom": { - "name": "role", - "kind": { - "Enum": [ - "admin", - "maintainer", - "read_only" - ] - } - } - } - ] - }, - "nullable": [ - true - ] - }, - "hash": "f095f62c28c250b56f649aa52bc283a1b1da03deb5378d6cfae9dbe2ba8218bd" -} From d2c41e01b96e02c975cbc47793fdbd1cb84f1aa2 Mon Sep 17 00:00:00 2001 From: Michiel Date: Mon, 23 Mar 2026 12:05:13 +0100 Subject: [PATCH 6/9] Fix playwright test --- .../src/components/admin/ApiUserOverview.tsx | 4 +- frontend/tests/adminSettings.spec.ts | 38 ++++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/admin/ApiUserOverview.tsx b/frontend/src/components/admin/ApiUserOverview.tsx index 4c60753c..d2483b7d 100644 --- a/frontend/src/components/admin/ApiUserOverview.tsx +++ b/frontend/src/components/admin/ApiUserOverview.tsx @@ -46,8 +46,8 @@ export default function ApiUserOverview() { )} - - + + {user.org_roles.length.toString()} diff --git a/frontend/tests/adminSettings.spec.ts b/frontend/tests/adminSettings.spec.ts index 4b54e0c8..2a04af47 100644 --- a/frontend/tests/adminSettings.spec.ts +++ b/frontend/tests/adminSettings.spec.ts @@ -1,7 +1,7 @@ // As we directly import from playwright/test, we are not logged in automatically. import { test, expect } from "@playwright/test"; -test("change global role", async ({ page }) => { +test("manage API user", async ({ page }) => { await page.goto("http://localhost:3000/login"); // Use login as super admin @@ -15,20 +15,40 @@ test("change global role", async ({ page }) => { await page.getByRole("tab", { name: "Users" }).click(); // make sure all operations take place in the correct row - const row = page.getByRole("row", { name: "Test API User 5" }); - - // click role dropdown - await row.getByRole("cell").nth(3).getByRole("textbox").click(); + const row = page.getByRole("row").filter({ hasText: "Test API User 5" }); + const overlay = page.getByRole('dialog', { name: 'Manage user Test API User 5' }); // make user admin + await row.getByRole("button").locator(".tabler-icon.tabler-icon-edit").click(); + await overlay.getByLabel("Global role").click(); await page.getByRole("option", { name: "admin" }).click(); + await overlay.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("User updated")).toBeVisible(); + await page.locator('.mantine-Modal-overlay').click(); // close overlay // check the user is actually admin now - await expect(row.getByRole("cell").nth(3).getByRole("textbox")).toHaveValue("admin"); + const nameCell = row.getByRole("cell").nth(1); + await expect(nameCell).toContainText("Admin"); + + // make user non-admin and block them + await row.getByRole("button").locator(".tabler-icon.tabler-icon-edit").click(); + await overlay.getByLabel("Global role").click(); + await page.getByRole("option", { name: "admin" }).click(); + await overlay.getByLabel("Block user from accessing Remails").click(); + await overlay.getByRole("button", { name: "Save" }).click(); + await page.locator('.mantine-Modal-overlay').click(); // close overlay + + // check again + await expect(nameCell).not.toContainText("Admin"); + await expect(nameCell).toContainText("Blocked"); - // make user non-admin again - await row.getByRole("cell").nth(3).locator("svg").first().click(); + // unblock user again + await row.getByRole("button").locator(".tabler-icon.tabler-icon-edit").click(); + await overlay.getByLabel("Block user from accessing Remails").click(); + await overlay.getByRole("button", { name: "Save" }).click(); + await page.locator('.mantine-Modal-overlay').click(); // close overlay // check again - await expect(row.getByRole("cell").nth(3).getByRole("textbox")).toBeEmpty(); + await expect(nameCell).not.toContainText("Admin"); + await expect(nameCell).not.toContainText("Blocked"); }); From 676960dbbfcf2df7a7462defbd4e94ba9269bd22 Mon Sep 17 00:00:00 2001 From: Michiel Date: Mon, 23 Mar 2026 16:08:28 +0100 Subject: [PATCH 7/9] Move organization admin management to overview overlay --- frontend/src/Pages.tsx | 2 - .../src/components/admin/ApiUserOverview.tsx | 23 ++-- .../components/admin/ManageOrganization.tsx | 117 ++++++++++++++++++ frontend/src/components/admin/OrgBlock.tsx | 89 ------------- .../admin/OrganizationsOverview.tsx | 87 ++++++------- frontend/src/layout/NavBar.tsx | 10 -- frontend/src/routes.ts | 4 - 7 files changed, 174 insertions(+), 158 deletions(-) create mode 100644 frontend/src/components/admin/ManageOrganization.tsx delete mode 100644 frontend/src/components/admin/OrgBlock.tsx diff --git a/frontend/src/Pages.tsx b/frontend/src/Pages.tsx index aaf80a30..eb0b3e2f 100644 --- a/frontend/src/Pages.tsx +++ b/frontend/src/Pages.tsx @@ -24,7 +24,6 @@ import { EmailOverview } from "./components/emails/EmailOverview.tsx"; import Members from "./components/organizations/Members.tsx"; import Subscription from "./components/organizations/Subscription.tsx"; import ApiKeysOverview from "./components/apiKeys/ApiKeysOverview.tsx"; -import OrgBlock from "./components/admin/OrgBlock.tsx"; import Suppressed from "./components/organizations/Suppressed.tsx"; const PageContent: { [key in RouteName]: JSX.Element | null } = { @@ -46,7 +45,6 @@ const PageContent: { [key in RouteName]: JSX.Element | null } = { "organization.API keys": , "organization.API keys.API key": , "organization.suppressed": , - "organization.admin": , admin: , "admin.organizations": , "admin.api_users": , diff --git a/frontend/src/components/admin/ApiUserOverview.tsx b/frontend/src/components/admin/ApiUserOverview.tsx index d2483b7d..4aae0705 100644 --- a/frontend/src/components/admin/ApiUserOverview.tsx +++ b/frontend/src/components/admin/ApiUserOverview.tsx @@ -18,14 +18,14 @@ export default function ApiUserOverview() { const [activePage, setPage] = useState(1); const { apiUsers } = useApiUsers(); - const { scrollIntoView, targetRef } = useScrollIntoView({ + const { scrollIntoView, targetRef } = useScrollIntoView({ duration: 500, offset: 100, }); const rows = apiUsers.slice((activePage - 1) * PER_PAGE, activePage * PER_PAGE).map((user) => ( - + @@ -40,7 +40,7 @@ export default function ApiUserOverview() { )} {user.blocked && ( - + Blocked )} @@ -52,9 +52,9 @@ export default function ApiUserOverview() { - {formatDateTime(user.updated_at)} - {formatDateTime(user.created_at)} - + {formatDateTime(user.updated_at)} + {formatDateTime(user.created_at)} + - - {config && organization.moneybird_contact_id && ( - - {organization.moneybird_contact_id} - - - )} - - + {organization.used_message_quota} / {organization.total_message_quota} - {formatDateTime(organization.updated_at)} - - { - e.preventDefault(); - navigate("organization.admin", { org_id: organization.id }); - }} - > + {formatDateTime(organization.updated_at)} + {formatDateTime(organization.created_at)} + + )); return ( <> - navigate("admin.organizations", { org_id: newOrg.id })} - /> - + + {rows} @@ -101,9 +107,6 @@ export default function OrganizationsOverview() { }} total={Math.ceil(organizations.length / PER_PAGE)} /> -
diff --git a/frontend/src/layout/NavBar.tsx b/frontend/src/layout/NavBar.tsx index 8ca05289..4b42b66a 100644 --- a/frontend/src/layout/NavBar.tsx +++ b/frontend/src/layout/NavBar.tsx @@ -44,7 +44,6 @@ export function NavBar({ close }: { close: () => void }) { navigate, } = useRemails(); const [openedNewOrg, { open: openNewOrg, close: closeNewOrg }] = useDisclosure(false); - const globalRole = user?.global_role || null; if (!user) { return null; @@ -133,15 +132,6 @@ export function NavBar({ close }: { close: () => void }) { active={routerState.name.startsWith("organization.suppressed")} /> - { - globalRole == "admin" && - - } ); diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 9d3b30fc..04fdd471 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -89,10 +89,6 @@ export const routes = [ name: "suppressed", path: "/suppressed", }, - { - name: "admin", - path: "/admin", - }, ], }, { From 9ecccc69274b71e5009a91dbe549d68bebecb444 Mon Sep 17 00:00:00 2001 From: Michiel Date: Wed, 25 Mar 2026 12:37:13 +0100 Subject: [PATCH 8/9] Add full freeze organization block status --- .../components/admin/ManageOrganization.tsx | 5 +- frontend/src/types.ts | 2 +- migrations/20260311155322_block_users.sql | 4 + src/api/api_keys.rs | 21 ++- src/api/auth.rs | 54 +++++--- src/api/invites.rs | 47 ++++--- src/api/messages.rs | 36 +++++ src/api/oauth/github.rs | 2 - src/api/organizations.rs | 106 ++++++++++++++- src/api/subscriptions.rs | 3 +- src/models/api_keys.rs | 51 ++++++-- src/models/api_user.rs | 123 +++++++----------- src/models/message.rs | 17 ++- src/models/organization.rs | 11 +- src/test.rs | 2 +- 15 files changed, 348 insertions(+), 136 deletions(-) diff --git a/frontend/src/components/admin/ManageOrganization.tsx b/frontend/src/components/admin/ManageOrganization.tsx index 6a0418b1..7659ce21 100644 --- a/frontend/src/components/admin/ManageOrganization.tsx +++ b/frontend/src/components/admin/ManageOrganization.tsx @@ -7,7 +7,10 @@ import { Button, Group, Modal, Select, Stack, Title } from "@mantine/core"; import { AdminButton } from "../RoleButtons"; import { IconTrash } from "@tabler/icons-react"; -const ALL_BLOCK_STATUSES: OrgBlockStatus[] = ["not_blocked", "no_sending", "no_sending_or_receiving"]; +const ALL_BLOCK_STATUSES: OrgBlockStatus[] = [ + "not_blocked", "no_sending", "no_sending_or_receiving", "full_freeze" +]; + export function isValidBlockStatus(value: string): value is OrgBlockStatus { return ALL_BLOCK_STATUSES.includes(value as OrgBlockStatus); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 71872584..85049495 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -264,7 +264,7 @@ export type Action = error: RemailsError; }; -export type OrgBlockStatus = "not_blocked" | "no_sending" | "no_sending_or_receiving"; +export type OrgBlockStatus = "not_blocked" | "no_sending" | "no_sending_or_receiving" | "full_freeze"; export type PasswordResetState = "NotActive" | "ActiveWithout2Fa" | "ActiveWith2Fa"; diff --git a/migrations/20260311155322_block_users.sql b/migrations/20260311155322_block_users.sql index 3aedb1fd..77632fbe 100644 --- a/migrations/20260311155322_block_users.sql +++ b/migrations/20260311155322_block_users.sql @@ -1 +1,5 @@ ALTER TABLE api_users ADD blocked boolean NOT NULL DEFAULT false; + +ALTER TYPE org_block_status ADD VALUE 'full_freeze'; + +ALTER TYPE org_role ADD ATTRIBUTE org_block_status org_block_status; diff --git a/src/api/api_keys.rs b/src/api/api_keys.rs index 4636f139..1309daa7 100644 --- a/src/api/api_keys.rs +++ b/src/api/api_keys.rs @@ -145,8 +145,9 @@ mod tests { api::tests::{TestServer, deserialize_body, serialize_body}, models::{ ApiDomain, ApiMessage, ApiMessageMetadata, CreatedApiKeyWithPassword, NewOrganization, - Organization, Project, Role, + Organization, OrganizationRepository, Project, Role, }, + test::TestProjects, }; use super::*; @@ -339,6 +340,24 @@ mod tests { test_api_key_no_access(server, StatusCode::FORBIDDEN, StatusCode::FORBIDDEN).await; } + #[sqlx::test(fixtures( + path = "../fixtures", + scripts("organizations", "api_users", "projects", "api_keys") + ))] + async fn test_api_key_no_access_frozen_org(pool: PgPool) { + let user_1 = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); // is admin of org 1 and 2 + let org_1 = TestProjects::Org1Project1.org_id(); + let server = TestServer::new(pool.clone(), Some(user_1)).await; + + let organizations = OrganizationRepository::new(pool); + organizations + .update_block_status(org_1, crate::models::OrgBlockStatus::FullFreeze) + .await + .unwrap(); + + test_api_key_no_access(server, StatusCode::OK, StatusCode::FORBIDDEN).await; + } + impl TestServer { pub async fn use_api_key(&mut self, org_id: OrganizationId, role: Role) -> ApiKeyId { // request an API key using the currently logged-in user diff --git a/src/api/auth.rs b/src/api/auth.rs index 8f34f867..17567053 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -2,7 +2,8 @@ use crate::{ api::{ApiState, error::AppError, validation::ValidatedJson, whoami::WhoamiResponse}, models::{ ApiKey, ApiKeyRepository, ApiUser, ApiUserId, ApiUserRepository, NewApiUser, - OrganizationId, Password, Role, RuntimeConfigRepository, TotpCode, + OrgBlockStatus, OrganizationId, OrganizationRepository, Password, Role, + RuntimeConfigRepository, TotpCode, }, system_emails::send_password_reset_email, }; @@ -92,12 +93,20 @@ impl ApiUser { impl Authenticated for ApiUser { fn is_at_least(&self, org_id: &OrganizationId, role: Role) -> bool { - !self.blocked - && (self.is_super_admin() - || self - .org_roles - .iter() - .any(|org_role| org_role.org_id == *org_id && org_role.role.is_at_least(role))) + if self.blocked { + return false; + } + + if self.is_super_admin() { + return true; + } + + self.org_roles.iter().any(|org_role| { + org_role.org_id == *org_id + && org_role.role.is_at_least(role) + && (org_role.org_block_status != OrgBlockStatus::FullFreeze + || role == Role::ReadOnly) + }) } fn viewable_organizations_filter(&self) -> Option> { @@ -120,7 +129,9 @@ impl Authenticated for ApiUser { impl Authenticated for ApiKey { fn is_at_least(&self, org_id: &OrganizationId, role: Role) -> bool { - org_id == self.organization_id() && self.role().is_at_least(role) + org_id == self.organization_id() + && self.role().is_at_least(role) + && (*self.org_block_status() != OrgBlockStatus::FullFreeze || role == Role::ReadOnly) } fn viewable_organizations_filter(&self) -> Option> { @@ -369,8 +380,6 @@ pub(super) async fn password_register( email: register_attempt.email, name: register_attempt.name.trim().to_string(), password: Some(register_attempt.password), - global_role: None, - org_roles: vec![], github_user_id: None, }; @@ -490,6 +499,7 @@ where S: Send + Sync, ApiState: FromRef, ApiUserRepository: FromRef, + OrganizationRepository: FromRef, { type Rejection = AppError; @@ -513,13 +523,21 @@ where trace!("Test log in based on `X-Test-Login` header"); return match header.to_str().unwrap() { "admin" => Ok(ApiUser::new(Some(Role::Admin), vec![])), - token => Ok(ApiUser::new( - None, - vec![crate::models::OrgRole { - role: Role::Admin, - org_id: token.parse().unwrap(), - }], - )), + token => { + let org_id = token.parse().unwrap(); + Ok(ApiUser::new( + None, + vec![crate::models::OrgRole { + role: Role::Admin, + org_id, + org_block_status: OrganizationRepository::from_ref(state) + .get_by_id(org_id) + .await? + .ok_or(AppError::Unauthorized)? + .block_status(), + }], + )) + } }; } else if let Some(header) = parts.headers.get("X-Test-Login-ID") { trace!("Test log in based on `X-Test-Login-ID` header"); @@ -576,6 +594,7 @@ where S: Send + Sync, ApiState: FromRef, ApiUserRepository: FromRef, + OrganizationRepository: FromRef, { type Rejection = (StatusCode, &'static str); @@ -641,6 +660,7 @@ where ApiState: FromRef, ApiUserRepository: FromRef, ApiKeyRepository: FromRef, + OrganizationRepository: FromRef, { type Rejection = AppError; diff --git a/src/api/invites.rs b/src/api/invites.rs index f226c66a..64f012b2 100644 --- a/src/api/invites.rs +++ b/src/api/invites.rs @@ -193,8 +193,9 @@ mod tests { }, bus::client::BusClient, handler::dns::DnsResolver, - models::{CreatedInviteWithPassword, OrgRole, Organization, Role}, + models::{CreatedInviteWithPassword, OrgBlockStatus, OrgRole, Organization, Role}, periodically::Periodically, + test::TestProjects, }; #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] @@ -262,7 +263,8 @@ mod tests { assert_eq!(whoami.id, user_2); assert!(whoami.org_roles.contains(&OrgRole { role: Role::Admin, - org_id: org_1.parse().unwrap() + org_id: org_1.parse().unwrap(), + org_block_status: OrgBlockStatus::NotBlocked, })); // now user_2 can see the invites of org_1 @@ -307,6 +309,7 @@ mod tests { assert!(whoami.org_roles.contains(&OrgRole { role: Role::Admin, org_id: org_1, + org_block_status: OrgBlockStatus::NotBlocked, })); org_repo.remove_member(org_1, user_3).await.unwrap(); @@ -332,6 +335,7 @@ mod tests { assert!(whoami.org_roles.contains(&OrgRole { role: Role::Maintainer, org_id: org_1, + org_block_status: OrgBlockStatus::NotBlocked, })); org_repo.remove_member(org_1, user_3).await.unwrap(); @@ -357,13 +361,14 @@ mod tests { assert!(whoami.org_roles.contains(&OrgRole { role: Role::ReadOnly, org_id: org_1, + org_block_status: OrgBlockStatus::NotBlocked, })); org_repo.remove_member(org_1, user_3).await.unwrap(); } async fn test_invites_no_access( - server: &mut TestServer, + server: TestServer, read_status_code: StatusCode, write_status_code: StatusCode, ) { @@ -410,33 +415,43 @@ mod tests { #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users", "invites")))] async fn test_invites_no_access_wrong_user(pool: PgPool) { let user_3 = "54432300-128a-46a0-8a83-fe39ce3ce5ef".parse().unwrap(); // is not in any org - let mut server = TestServer::new(pool, Some(user_3)).await; - test_invites_no_access(&mut server, StatusCode::FORBIDDEN, StatusCode::FORBIDDEN).await; + let server = TestServer::new(pool, Some(user_3)).await; + test_invites_no_access(server, StatusCode::FORBIDDEN, StatusCode::FORBIDDEN).await; } #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users", "invites")))] async fn test_invites_no_access_non_admin(pool: PgPool) { let user_4 = "c33dbd88-43ed-404b-9367-1659a73c8f3a".parse().unwrap(); // maintainer of org 1 - let mut server = TestServer::new(pool, Some(user_4)).await; - test_invites_no_access(&mut server, StatusCode::OK, StatusCode::FORBIDDEN).await; + let server = TestServer::new(pool, Some(user_4)).await; + test_invites_no_access(server, StatusCode::OK, StatusCode::FORBIDDEN).await; } #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users", "invites")))] async fn test_invites_no_access_not_logged_in(pool: PgPool) { - let mut server = TestServer::new(pool, None).await; - test_invites_no_access( - &mut server, - StatusCode::UNAUTHORIZED, - StatusCode::UNAUTHORIZED, - ) - .await; + let server = TestServer::new(pool, None).await; + test_invites_no_access(server, StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED).await; } #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users", "invites")))] async fn test_invites_no_access_blocked_user(pool: PgPool) { let blocked_user = "b0c918e3-8183-430f-83eb-78b182ebef9e".parse().unwrap(); // blocked admin of org 1 - let mut server = TestServer::new(pool, Some(blocked_user)).await; - test_invites_no_access(&mut server, StatusCode::FORBIDDEN, StatusCode::FORBIDDEN).await; + let server = TestServer::new(pool, Some(blocked_user)).await; + test_invites_no_access(server, StatusCode::FORBIDDEN, StatusCode::FORBIDDEN).await; + } + + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users", "invites")))] + async fn test_invites_no_access_frozen_org(pool: PgPool) { + let user_1 = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); // is admin of org 1 and 2 + let org_1 = TestProjects::Org1Project1.org_id(); + let server = TestServer::new(pool.clone(), Some(user_1)).await; + + let organizations = OrganizationRepository::new(pool); + organizations + .update_block_status(org_1, crate::models::OrgBlockStatus::FullFreeze) + .await + .unwrap(); + + test_invites_no_access(server, StatusCode::OK, StatusCode::FORBIDDEN).await; } #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users", "invites")))] diff --git a/src/api/messages.rs b/src/api/messages.rs index dfb1b939..bd95069b 100644 --- a/src/api/messages.rs +++ b/src/api/messages.rs @@ -786,6 +786,35 @@ mod tests { test_messages_no_access(server, StatusCode::FORBIDDEN, StatusCode::FORBIDDEN).await; } + #[sqlx::test(fixtures( + path = "../fixtures", + scripts( + "organizations", + "api_users", + "projects", + "smtp_credentials", + "messages" + ) + ))] + async fn test_messages_no_access_frozen_org(pool: PgPool) { + let user_1 = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); // is admin of org 1 and 2 + let org_1 = TestProjects::Org1Project1.org_id(); + + // with API key + let mut server = TestServer::new(pool.clone(), Some(user_1)).await; + server.use_api_key(org_1, Role::Maintainer).await; + let organizations = OrganizationRepository::new(pool.clone()); + organizations + .update_block_status(org_1, crate::models::OrgBlockStatus::FullFreeze) + .await + .unwrap(); + test_messages_no_access(server, StatusCode::OK, StatusCode::FORBIDDEN).await; + + // without API key + let server = TestServer::new(pool.clone(), Some(user_1)).await; + test_messages_no_access(server, StatusCode::OK, StatusCode::FORBIDDEN).await; + } + #[sqlx::test(fixtures( path = "../fixtures", scripts( @@ -1128,6 +1157,13 @@ mod tests { .unwrap(); let response = try_post(&server).await.unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); // blocked + + organizations + .update_block_status(org_1, crate::models::OrgBlockStatus::FullFreeze) + .await + .unwrap(); + let response = try_post(&server).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); // also blocked } #[sqlx::test(fixtures( diff --git a/src/api/oauth/github.rs b/src/api/oauth/github.rs index 8960a879..17ce01ec 100644 --- a/src/api/oauth/github.rs +++ b/src/api/oauth/github.rs @@ -186,8 +186,6 @@ impl GithubOauthService { email, name: github_user.name.unwrap_or(github_user.login), password: None, - global_role: None, - org_roles: vec![], github_user_id: Some(github_user.id), }) .await?) diff --git a/src/api/organizations.rs b/src/api/organizations.rs index bf5a38f0..83b747e6 100644 --- a/src/api/organizations.rs +++ b/src/api/organizations.rs @@ -288,6 +288,16 @@ pub async fn remove_member( .ok_or(AppError::Forbidden), ))?; + // if organization is frozen, you cannot remove yourself + if !user.is_super_admin() + && user + .org_roles + .iter() + .any(|r| r.org_id == org_id && r.org_block_status == OrgBlockStatus::FullFreeze) + { + return Err(AppError::Forbidden); + } + if user.has_org_admin_access(&org_id).is_ok() && *user.id() == user_id { prevent_last_remaining_admin(&repo, &org_id, &user_id).await?; } @@ -382,6 +392,7 @@ mod tests { whoami::WhoamiResponse, }, models::{OrgRole, Role, RuntimeConfig}, + test::TestProjects, }; use super::*; @@ -469,7 +480,8 @@ mod tests { whoami.org_roles[0], OrgRole { role: Role::ReadOnly, - org_id: created_org.id() + org_id: created_org.id(), + org_block_status: OrgBlockStatus::NotBlocked, } ); @@ -514,7 +526,8 @@ mod tests { whoami.org_roles[0], OrgRole { role: Role::Admin, - org_id: created_org.id() + org_id: created_org.id(), + org_block_status: OrgBlockStatus::NotBlocked, } ); @@ -692,6 +705,28 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] + async fn test_organization_no_access_blocked_user(pool: PgPool) { + let blocked_user = "b0c918e3-8183-430f-83eb-78b182ebef9e".parse().unwrap(); // blocked admin of org 1 + let server = TestServer::new(pool.clone(), Some(blocked_user)).await; + test_organization_no_access(&server, StatusCode::FORBIDDEN, StatusCode::FORBIDDEN).await; + } + + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] + async fn test_organization_no_access_frozen_org(pool: PgPool) { + let user_1 = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); // is admin of org 1 and 2 + let org_1 = TestProjects::Org1Project1.org_id(); + let server = TestServer::new(pool.clone(), Some(user_1)).await; + + let organizations = OrganizationRepository::new(pool.clone()); + organizations + .update_block_status(org_1, crate::models::OrgBlockStatus::FullFreeze) + .await + .unwrap(); + + test_organization_no_access(&server, StatusCode::OK, StatusCode::FORBIDDEN).await; + } + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] async fn test_organization_no_access_admin(pool: PgPool) { let org_1 = "44729d9f-a7dc-4226-b412-36a7537f5176"; @@ -709,6 +744,73 @@ mod tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); } + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] + async fn test_super_admins_can_unfreeze_organization(pool: PgPool) { + let org_1 = "44729d9f-a7dc-4226-b412-36a7537f5176"; + let admin = "deadbeef-4e43-4a66-bbb9-fbcd4a933a34".parse().unwrap(); // is super admin + let user_1 = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); // is admin of org 1 and 2 + let mut server = TestServer::new(pool.clone(), Some(admin)).await; + + // admin can freeze + let response = server + .put( + format!("/api/organizations/{org_1}/admin"), + serialize_body(OrgBlockStatus::FullFreeze), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // user can't update organization + server.set_user(Some(user_1)); + let response = server + .put( + format!("/api/organizations/{org_1}"), + serialize_body(&NewOrganization { + name: "Updated Org".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // admin can update organization (bypassing the freeze) + server.set_user(Some(admin)); + let response = server + .put( + format!("/api/organizations/{org_1}"), + serialize_body(&NewOrganization { + name: "Admin Updated Org".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // admin can unfreeze + let response = server + .put( + format!("/api/organizations/{org_1}/admin"), + serialize_body(OrgBlockStatus::NotBlocked), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // user can update organization + server.set_user(Some(user_1)); + let response = server + .put( + format!("/api/organizations/{org_1}"), + serialize_body(&NewOrganization { + name: "Updated Org".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] async fn test_organization_members(pool: PgPool) { let user_1 = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); // is admin of org 1 and 2 diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index 952d448b..ac398ff6 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -149,7 +149,7 @@ mod test { tests::{TestServer, deserialize_body, serialize_body}, whoami::WhoamiResponse, }, - models::{OrgRole, Organization, Role}, + models::{OrgBlockStatus, OrgRole, Organization, Role}, }; use chrono::Utc; use http::StatusCode; @@ -247,6 +247,7 @@ mod test { OrgRole { role: Role::Admin, org_id: "ad76a517-3ff2-4d84-8299-742847782d4d".parse().unwrap(), + org_block_status: OrgBlockStatus::NotBlocked, } ); } diff --git a/src/models/api_keys.rs b/src/models/api_keys.rs index 76b09129..8228da53 100644 --- a/src/models/api_keys.rs +++ b/src/models/api_keys.rs @@ -7,7 +7,7 @@ use sqlx::PgPool; use utoipa::ToSchema; use uuid::Uuid; -use crate::models::{Error, OrganizationId, Password, Role}; +use crate::models::{Error, OrgBlockStatus, OrganizationId, Password, Role}; #[derive( Debug, Clone, Copy, Deserialize, Serialize, PartialEq, From, Display, Deref, FromStr, ToSchema, @@ -22,6 +22,7 @@ pub struct ApiKey { #[serde(skip)] password_hash: String, organization_id: OrganizationId, + org_block_status: OrgBlockStatus, role: Role, created_at: DateTime, updated_at: DateTime, @@ -40,6 +41,10 @@ impl ApiKey { &self.organization_id } + pub fn org_block_status(&self) -> &OrgBlockStatus { + &self.org_block_status + } + pub fn role(&self) -> &Role { &self.role } @@ -125,9 +130,17 @@ impl ApiKeyRepository { let api_key = sqlx::query_as!( ApiKey, r#" - INSERT INTO api_keys (id, description, password_hash, organization_id, role) - VALUES (gen_random_uuid(), $1, $2, $3, $4) - RETURNING id, description, password_hash, organization_id, role as "role: Role", created_at, updated_at + WITH inserted AS ( + INSERT INTO api_keys (id, description, password_hash, organization_id, role) + VALUES (gen_random_uuid(), $1, $2, $3, $4) + RETURNING * + ) + SELECT i.id, i.description, i.password_hash, i.organization_id, + o.block_status as "org_block_status!: OrgBlockStatus", + i.role as "role: Role", + i.created_at, i.updated_at + FROM inserted i + LEFT JOIN organizations o ON o.id = i.organization_id "#, key.description, password_hash, @@ -152,9 +165,13 @@ impl ApiKeyRepository { Ok(sqlx::query_as!( ApiKey, r#" - SELECT id, description, password_hash, organization_id, role as "role: Role", created_at, updated_at - FROM api_keys - WHERE id = $1 + SELECT a.id, description, password_hash, organization_id, + o.block_status as "org_block_status: OrgBlockStatus", + role as "role: Role", + a.created_at, a.updated_at + FROM api_keys a + LEFT JOIN organizations o ON o.id = a.organization_id + WHERE a.id = $1 "#, *key_id ) @@ -166,9 +183,13 @@ impl ApiKeyRepository { Ok(sqlx::query_as!( ApiKey, r#" - SELECT id, description, password_hash, organization_id, role as "role: Role", created_at, updated_at - FROM api_keys - WHERE organization_id = $1 + SELECT a.id, description, password_hash, organization_id, + o.block_status as "org_block_status: OrgBlockStatus", + role as "role: Role", + a.created_at, a.updated_at + FROM api_keys a + LEFT JOIN organizations o ON o.id = a.organization_id + WHERE a.organization_id = $1 "#, *org_id ) @@ -192,10 +213,14 @@ impl ApiKeyRepository { Ok(sqlx::query_as!( ApiKey, r#" - UPDATE api_keys + UPDATE api_keys a SET description = $1, role = $2 - WHERE organization_id = $3 AND id = $4 - RETURNING id, description, password_hash, organization_id, role as "role: Role", created_at, updated_at + FROM organizations o + WHERE a.organization_id = $3 AND a.id = $4 AND o.id = a.organization_id + RETURNING a.id, description, password_hash, organization_id, + o.block_status as "org_block_status: OrgBlockStatus", + role as "role: Role", + a.created_at, a.updated_at "#, changes.description, changes.role as Role, diff --git a/src/models/api_user.rs b/src/models/api_user.rs index a223fdbb..9babf36b 100644 --- a/src/models/api_user.rs +++ b/src/models/api_user.rs @@ -1,4 +1,4 @@ -use crate::models::{Error, OrganizationId}; +use crate::models::{Error, OrgBlockStatus, OrganizationId}; use chrono::{DateTime, Utc}; use derive_more::{Deref, Display, From, FromStr}; use email_address::EmailAddress; @@ -119,6 +119,7 @@ impl Role { pub struct OrgRole { pub role: Role, pub org_id: OrganizationId, + pub org_block_status: OrgBlockStatus, } #[derive(Debug)] @@ -126,8 +127,6 @@ pub struct NewApiUser { pub email: EmailAddress, pub name: String, pub password: Option, - pub global_role: Option, - pub org_roles: Vec, pub github_user_id: Option, } @@ -226,6 +225,7 @@ impl ApiUser { struct PgOrgRole { org_id: Option, role: Option, + org_block_status: Option, } impl From for Option { @@ -233,6 +233,7 @@ impl From for Option { Some(OrgRole { org_id: role.org_id?, role: role.role?, + org_block_status: role.org_block_status?, }) } } @@ -304,39 +305,20 @@ impl ApiUserRepository { pub async fn create(&self, user: NewApiUser) -> Result { let password_hash = user.password.map(|pw| pw.generate_hash()); - let mut tx = self.pool.begin().await?; - let user_id = sqlx::query_scalar!( r#" - INSERT INTO api_users (id, email, name, password_hash, github_user_id, global_role) - VALUES (gen_random_uuid(), $1, $2, $3, $4, $5) + INSERT INTO api_users (id, email, name, password_hash, github_user_id) + VALUES (gen_random_uuid(), $1, $2, $3, $4) RETURNING id "#, user.email.as_str(), user.name, password_hash, user.github_user_id, - user.global_role as Option ) - .fetch_one(&mut *tx) + .fetch_one(&self.pool) .await?; - for OrgRole { role, org_id } in user.org_roles { - sqlx::query!( - r#" - INSERT INTO api_users_organizations (api_user_id, organization_id, role) - VALUES ($1, $2, $3) - "#, - user_id, - *org_id, - role as Role - ) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - Ok(self.find_by_id(&user_id.into()).await?.unwrap()) } @@ -544,23 +526,26 @@ impl ApiUserRepository { u.email, u.name, u.github_user_id, - array_agg((o.organization_id,o.role)::org_role)::org_role[] AS "organization_roles!: Vec", + array_agg( + (r.organization_id, r.role, o.block_status)::org_role + )::org_role[] AS "organization_roles!: Vec", u.global_role AS "global_role: Role", u.password_hash IS NOT NULL AS "password_enabled!", u.blocked, u.updated_at, u.created_at FROM api_users u - LEFT JOIN api_users_organizations o ON u.id = o.api_user_id + LEFT JOIN api_users_organizations r ON u.id = r.api_user_id + LEFT JOIN organizations o ON r.organization_id = o.id WHERE github_user_id = $1 GROUP BY u.id "#, github_id ) - .fetch_optional(&self.pool) - .await? - .map(TryInto::try_into) - .transpose() + .fetch_optional(&self.pool) + .await? + .map(TryInto::try_into) + .transpose() } pub async fn add_github_id(&self, user_id: &ApiUserId, github_id: i64) -> Result<(), Error> { @@ -876,23 +861,26 @@ impl ApiUserRepository { u.email, u.name, u.github_user_id, - array_agg((o.organization_id,o.role)::org_role)::org_role[] AS "organization_roles!: Vec", + array_agg( + (r.organization_id, r.role, o.block_status)::org_role + )::org_role[] AS "organization_roles!: Vec", u.global_role AS "global_role: Role", u.password_hash IS NOT NULL AS "password_enabled!", u.blocked, u.updated_at, u.created_at FROM api_users u - LEFT JOIN api_users_organizations o ON u.id = o.api_user_id + LEFT JOIN api_users_organizations r ON u.id = r.api_user_id + LEFT JOIN organizations o ON r.organization_id = o.id WHERE u.id = $1 GROUP BY u.id "#, **id ) - .fetch_optional(&self.pool) - .await? - .map(TryInto::try_into) - .transpose() + .fetch_optional(&self.pool) + .await? + .map(TryInto::try_into) + .transpose() } pub async fn get_all(&self) -> Result, Error> { @@ -903,23 +891,26 @@ impl ApiUserRepository { u.email, u.name, u.github_user_id, - array_agg((o.organization_id,o.role)::org_role)::org_role[] AS "organization_roles!: Vec", + array_agg( + (r.organization_id, r.role, o.block_status)::org_role + )::org_role[] AS "organization_roles!: Vec", u.global_role AS "global_role: Role", u.password_hash IS NOT NULL AS "password_enabled!", u.blocked, u.updated_at, u.created_at FROM api_users u - LEFT JOIN api_users_organizations o ON u.id = o.api_user_id + LEFT JOIN api_users_organizations r ON u.id = r.api_user_id + LEFT JOIN organizations o ON r.organization_id = o.id GROUP BY u.id ORDER BY u.updated_at DESC "# ) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(TryInto::try_into) - .collect() + .fetch_all(&self.pool) + .await? + .into_iter() + .map(TryInto::try_into) + .collect() } pub async fn find_by_email(&self, email: &EmailAddress) -> Result, Error> { @@ -930,23 +921,26 @@ impl ApiUserRepository { u.email, u.name, u.github_user_id, - array_agg((o.organization_id,o.role)::org_role)::org_role[] AS "organization_roles!: Vec", + array_agg( + (r.organization_id, r.role, o.block_status)::org_role + )::org_role[] AS "organization_roles!: Vec", u.global_role AS "global_role: Role", u.password_hash IS NOT NULL AS "password_enabled!", u.blocked, u.updated_at, u.created_at FROM api_users u - LEFT JOIN api_users_organizations o ON u.id = o.api_user_id + LEFT JOIN api_users_organizations r ON u.id = r.api_user_id + LEFT JOIN organizations o ON r.organization_id = o.id WHERE u.email = $1 GROUP BY u.id "#, email.as_str() ) - .fetch_optional(&self.pool) - .await? - .map(TryInto::try_into) - .transpose() + .fetch_optional(&self.pool) + .await? + .map(TryInto::try_into) + .transpose() } pub async fn check_password( @@ -1027,17 +1021,9 @@ mod test { impl PartialEq for ApiUser { fn eq(&self, other: &NewApiUser) -> bool { - let mut other_org_roles = other.org_roles.clone(); - other_org_roles.sort(); - - let mut self_org_roles = self.org_roles.clone(); - self_org_roles.sort(); - self.github_user_id == other.github_user_id && self.email.as_ref() == Some(&other.email) && self.name == other.name - && self.global_role == other.global_role - && self_org_roles == other_org_roles } } @@ -1047,8 +1033,6 @@ mod test { email: self.email.clone(), name: self.name.clone(), password: None, - global_role: self.global_role, - org_roles: self.org_roles.clone(), github_user_id: self.github_user_id, } } @@ -1059,31 +1043,14 @@ mod test { let repo = ApiUserRepository::new(db); let users = repo.get_all().await.unwrap().len(); - let user = NewApiUser { - email: "test@email.com".parse().unwrap(), - name: "Test User".to_string(), - password: None, - global_role: Some(Role::Admin), - org_roles: vec![OrgRole { - role: Role::Admin, - org_id: "44729d9f-a7dc-4226-b412-36a7537f5176".parse().unwrap(), - }], - github_user_id: Some(123), - }; - let created = repo.create(user.clone()).await.unwrap(); - assert_eq!(created, user); - assert_eq!(repo.get_all().await.unwrap().len(), users + 1); - let user = NewApiUser { email: "test2@email.com".parse().unwrap(), name: "Test User 2".to_string(), password: None, - global_role: None, - org_roles: vec![], github_user_id: None, }; let created = repo.create(user.clone()).await.unwrap(); assert_eq!(created, user); - assert_eq!(repo.get_all().await.unwrap().len(), users + 2); + assert_eq!(repo.get_all().await.unwrap().len(), users + 1); } } diff --git a/src/models/message.rs b/src/models/message.rs index b835289c..311b9d6b 100644 --- a/src/models/message.rs +++ b/src/models/message.rs @@ -1050,7 +1050,7 @@ impl MessageRepository { trace!("checking rate limit"); - if org.block_status == OrgBlockStatus::NoSendingOrReceiving { + if org.block_status >= OrgBlockStatus::NoSendingOrReceiving { trace!(project_id = id.to_string(), "organization blocked"); return Err(Error::OrgBlocked); } @@ -1460,6 +1460,21 @@ mod test { .unwrap_err(); assert!(matches!(err, Error::OrgBlocked)); // can't receive + // set org 1 to Full Freeze + organizations + .update_block_status(org_id, OrgBlockStatus::FullFreeze) + .await + .unwrap(); + + let err = messages.get_if_org_may_send(message_id).await.unwrap_err(); // can't send + assert!(matches!(err, Error::NotFound(_))); + + let err = messages + .email_creation_rate_limit(proj_id) + .await + .unwrap_err(); + assert!(matches!(err, Error::OrgBlocked)); // can't receive + // reset org 1 to Not Blocked organizations .update_block_status(org_id, OrgBlockStatus::NotBlocked) diff --git a/src/models/organization.rs b/src/models/organization.rs index 1912aad1..30ca6a1f 100644 --- a/src/models/organization.rs +++ b/src/models/organization.rs @@ -46,6 +46,8 @@ impl OrganizationId { Display, PartialEq, PartialOrd, + Eq, + Ord, sqlx::Type, ToSchema, Validate, @@ -56,6 +58,7 @@ pub enum OrgBlockStatus { NotBlocked = 0, NoSending = 1, NoSendingOrReceiving = 2, + FullFreeze = 3, } #[derive(Debug, Serialize, PartialEq, ToSchema)] @@ -73,7 +76,7 @@ pub struct Organization { current_subscription: SubscriptionStatus, created_at: DateTime, updated_at: DateTime, - block_status: Option, + block_status: OrgBlockStatus, } impl Organization { @@ -84,6 +87,10 @@ impl Organization { pub fn current_subscription(&self) -> &SubscriptionStatus { &self.current_subscription } + + pub fn block_status(&self) -> OrgBlockStatus { + self.block_status + } } #[derive(Debug, Clone, Deserialize, Serialize, ToSchema, Validate)] @@ -126,7 +133,7 @@ struct PgOrganization { current_subscription: serde_json::Value, created_at: DateTime, updated_at: DateTime, - block_status: Option, + block_status: OrgBlockStatus, } impl TryFrom for Organization { diff --git a/src/test.rs b/src/test.rs index 72be0112..b0d2e433 100644 --- a/src/test.rs +++ b/src/test.rs @@ -296,7 +296,7 @@ async fn integration_test(pool: PgPool) { .get(format!( "http://localhost:{http_port}/api/organizations/{jorg}/emails" )) - .header("X-Test-Login", "00000000-0000-4000-0000-000000000000") // non-existent organization + .header("X-Test-Login", &eorg) .send() .await .unwrap() From aeb8dd779a4a1521689bcc0708e47f34b1a02fc9 Mon Sep 17 00:00:00 2001 From: Michiel Date: Wed, 25 Mar 2026 12:38:19 +0100 Subject: [PATCH 9/9] Prepare sqlx --- ...91301948c0c326bb7abc46b4e65ee553c6060.json | 101 ++++++++++++++++++ ...e3d526f7e805d33622257c384c38a36f40cd.json} | 22 +++- ...f6f78f23effc58497b43673789bfc76012ea2.json | 6 +- ...e77b964e3cfa0f8a82c377c2b94373a5f9528.json | 37 ------- ...835d25cf3c912358075b13a6d126c16bd3089.json | 25 +++++ ...1b060d90d8047b89d0aa807e5074f2aa40a3e.json | 3 +- ...686becdd314786d2157981ca07bdd5b409a4b.json | 69 ------------ ...bd5f4b671c7a984727770e49cda84e24a7ffe.json | 69 ------------ ...36c62e8f676c18e37ea310de793658ea9bb79.json | 101 ++++++++++++++++++ ...3bc83762463a19fa024dc285371874b1ec62.json} | 40 +++---- ...d842d0896040a61af984f6ef292e1d40da557.json | 3 +- ...c3dcf8cdcf8eea0c593bc51401cda35239a76.json | 27 ----- ...30da1d300b116f874692f0d37db009b85d568.json | 3 +- ...cf41c5ada97ee0220337b0adb4f0bfb5931fb.json | 3 +- ...789ba8d06efd905eae35e96f552f2143c5ba9.json | 3 +- ...d49e3086fb085949de75333cf17da6cbd7c0.json} | 22 +++- ...7eb8f419471c4fd75d999e2285aeec137b73.json} | 20 +++- ...7004c8050c1b42fecc99b6aed1e779695ac9.json} | 20 +++- ...805e15fa2cd926a5584a2edff2d3274eb8a3.json} | 42 ++++---- 19 files changed, 360 insertions(+), 256 deletions(-) create mode 100644 .sqlx/query-05f1c611cec5c652dce8c78dd3791301948c0c326bb7abc46b4e65ee553c6060.json rename .sqlx/{query-10621142ecab6bd0495d00f5dba52cb5f94457ca711e408570720dc03a45cdb8.json => query-069f666a147ef968d06b6e12f5f7e3d526f7e805d33622257c384c38a36f40cd.json} (65%) delete mode 100644 .sqlx/query-183eedcd766907281e4288245dce77b964e3cfa0f8a82c377c2b94373a5f9528.json create mode 100644 .sqlx/query-196d1dde40ca5e237e4c2abc90b835d25cf3c912358075b13a6d126c16bd3089.json delete mode 100644 .sqlx/query-2c63f63f5385c82c3ed8d2a7cd2686becdd314786d2157981ca07bdd5b409a4b.json delete mode 100644 .sqlx/query-2d54476fcbbdf73a5e8d7fd0779bd5f4b671c7a984727770e49cda84e24a7ffe.json create mode 100644 .sqlx/query-3393b6260718d6a22a4d00fe35c36c62e8f676c18e37ea310de793658ea9bb79.json rename .sqlx/{query-10d0da63de04975330348be009f00fa87da9e949d27fb41f693af7f3b2a35eb8.json => query-41e89cff27c833c5b033cd428abb3bc83762463a19fa024dc285371874b1ec62.json} (62%) delete mode 100644 .sqlx/query-52c638f75bf8945de535857ffe7c3dcf8cdcf8eea0c593bc51401cda35239a76.json rename .sqlx/{query-0ba37b2f107a269ddbdcf66f04a85e5e5bcc8a93005868d133327ae27d68ece8.json => query-6779d45a84f75d64bc64087f185ad49e3086fb085949de75333cf17da6cbd7c0.json} (66%) rename .sqlx/{query-3773efed49a9a3471fb8bfc2a9c4dda9d0f79b12ca0e07a925054aa2a2577371.json => query-68528f35eab8519a9b2236f995f87eb8f419471c4fd75d999e2285aeec137b73.json} (65%) rename .sqlx/{query-0531daf7468e9201fabcc22bc8a50d1b5d408cdae2f88f795ca283f926ff40a3.json => query-b1d35676a132fa6078f48f9c00c27004c8050c1b42fecc99b6aed1e779695ac9.json} (65%) rename .sqlx/{query-f5bf1f3c33751d74671538938f777a52d2a94fe4129b28658c24edddd09f6b58.json => query-df7e6d2ae740e9fe1b209f084beb805e15fa2cd926a5584a2edff2d3274eb8a3.json} (61%) diff --git a/.sqlx/query-05f1c611cec5c652dce8c78dd3791301948c0c326bb7abc46b4e65ee553c6060.json b/.sqlx/query-05f1c611cec5c652dce8c78dd3791301948c0c326bb7abc46b4e65ee553c6060.json new file mode 100644 index 00000000..8d905749 --- /dev/null +++ b/.sqlx/query-05f1c611cec5c652dce8c78dd3791301948c0c326bb7abc46b4e65ee553c6060.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH inserted AS (\n INSERT INTO api_keys (id, description, password_hash, organization_id, role)\n VALUES (gen_random_uuid(), $1, $2, $3, $4)\n RETURNING *\n )\n SELECT i.id, i.description, i.password_hash, i.organization_id,\n o.block_status as \"org_block_status!: OrgBlockStatus\",\n i.role as \"role: Role\",\n i.created_at, i.updated_at\n FROM inserted i\n LEFT JOIN organizations o ON o.id = i.organization_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "password_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "organization_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "org_block_status!: OrgBlockStatus", + "type_info": { + "Custom": { + "name": "org_block_status", + "kind": { + "Enum": [ + "not_blocked", + "no_sending", + "no_sending_or_receiving", + "full_freeze" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "role: Role", + "type_info": { + "Custom": { + "name": "role", + "kind": { + "Enum": [ + "admin", + "maintainer", + "read_only" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Uuid", + { + "Custom": { + "name": "role", + "kind": { + "Enum": [ + "admin", + "maintainer", + "read_only" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "05f1c611cec5c652dce8c78dd3791301948c0c326bb7abc46b4e65ee553c6060" +} diff --git a/.sqlx/query-10621142ecab6bd0495d00f5dba52cb5f94457ca711e408570720dc03a45cdb8.json b/.sqlx/query-069f666a147ef968d06b6e12f5f7e3d526f7e805d33622257c384c38a36f40cd.json similarity index 65% rename from .sqlx/query-10621142ecab6bd0495d00f5dba52cb5f94457ca711e408570720dc03a45cdb8.json rename to .sqlx/query-069f666a147ef968d06b6e12f5f7e3d526f7e805d33622257c384c38a36f40cd.json index 8292b8c8..e92ba64d 100644 --- a/.sqlx/query-10621142ecab6bd0495d00f5dba52cb5f94457ca711e408570720dc03a45cdb8.json +++ b/.sqlx/query-069f666a147ef968d06b6e12f5f7e3d526f7e805d33622257c384c38a36f40cd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n WHERE u.id = $1\n GROUP BY u.id\n ", + "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg(\n (r.organization_id, r.role, o.block_status)::org_role\n )::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations r ON u.id = r.api_user_id\n LEFT JOIN organizations o ON r.organization_id = o.id\n WHERE u.email = $1\n GROUP BY u.id\n ", "describe": { "columns": [ { @@ -53,6 +53,22 @@ } } } + ], + [ + "org_block_status", + { + "Custom": { + "name": "org_block_status", + "kind": { + "Enum": [ + "not_blocked", + "no_sending", + "no_sending_or_receiving", + "full_freeze" + ] + } + } + } ] ] } @@ -101,7 +117,7 @@ ], "parameters": { "Left": [ - "Uuid" + "Text" ] }, "nullable": [ @@ -117,5 +133,5 @@ false ] }, - "hash": "10621142ecab6bd0495d00f5dba52cb5f94457ca711e408570720dc03a45cdb8" + "hash": "069f666a147ef968d06b6e12f5f7e3d526f7e805d33622257c384c38a36f40cd" } diff --git a/.sqlx/query-0b83e92d7fec626834425579197f6f78f23effc58497b43673789bfc76012ea2.json b/.sqlx/query-0b83e92d7fec626834425579197f6f78f23effc58497b43673789bfc76012ea2.json index 56ec6def..fc914595 100644 --- a/.sqlx/query-0b83e92d7fec626834425579197f6f78f23effc58497b43673789bfc76012ea2.json +++ b/.sqlx/query-0b83e92d7fec626834425579197f6f78f23effc58497b43673789bfc76012ea2.json @@ -68,7 +68,8 @@ "Enum": [ "not_blocked", "no_sending", - "no_sending_or_receiving" + "no_sending_or_receiving", + "full_freeze" ] } } @@ -85,7 +86,8 @@ "Enum": [ "not_blocked", "no_sending", - "no_sending_or_receiving" + "no_sending_or_receiving", + "full_freeze" ] } } diff --git a/.sqlx/query-183eedcd766907281e4288245dce77b964e3cfa0f8a82c377c2b94373a5f9528.json b/.sqlx/query-183eedcd766907281e4288245dce77b964e3cfa0f8a82c377c2b94373a5f9528.json deleted file mode 100644 index 272fa2c0..00000000 --- a/.sqlx/query-183eedcd766907281e4288245dce77b964e3cfa0f8a82c377c2b94373a5f9528.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO api_users (id, email, name, password_hash, github_user_id, global_role)\n VALUES (gen_random_uuid(), $1, $2, $3, $4, $5)\n RETURNING id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Int8", - { - "Custom": { - "name": "role", - "kind": { - "Enum": [ - "admin", - "maintainer", - "read_only" - ] - } - } - } - ] - }, - "nullable": [ - false - ] - }, - "hash": "183eedcd766907281e4288245dce77b964e3cfa0f8a82c377c2b94373a5f9528" -} diff --git a/.sqlx/query-196d1dde40ca5e237e4c2abc90b835d25cf3c912358075b13a6d126c16bd3089.json b/.sqlx/query-196d1dde40ca5e237e4c2abc90b835d25cf3c912358075b13a6d126c16bd3089.json new file mode 100644 index 00000000..48f2a0d0 --- /dev/null +++ b/.sqlx/query-196d1dde40ca5e237e4c2abc90b835d25cf3c912358075b13a6d126c16bd3089.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO api_users (id, email, name, password_hash, github_user_id)\n VALUES (gen_random_uuid(), $1, $2, $3, $4)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "196d1dde40ca5e237e4c2abc90b835d25cf3c912358075b13a6d126c16bd3089" +} diff --git a/.sqlx/query-2a16557f0bf583441f24b9c070e1b060d90d8047b89d0aa807e5074f2aa40a3e.json b/.sqlx/query-2a16557f0bf583441f24b9c070e1b060d90d8047b89d0aa807e5074f2aa40a3e.json index 49fcfc87..d908a4aa 100644 --- a/.sqlx/query-2a16557f0bf583441f24b9c070e1b060d90d8047b89d0aa807e5074f2aa40a3e.json +++ b/.sqlx/query-2a16557f0bf583441f24b9c070e1b060d90d8047b89d0aa807e5074f2aa40a3e.json @@ -68,7 +68,8 @@ "Enum": [ "not_blocked", "no_sending", - "no_sending_or_receiving" + "no_sending_or_receiving", + "full_freeze" ] } } diff --git a/.sqlx/query-2c63f63f5385c82c3ed8d2a7cd2686becdd314786d2157981ca07bdd5b409a4b.json b/.sqlx/query-2c63f63f5385c82c3ed8d2a7cd2686becdd314786d2157981ca07bdd5b409a4b.json deleted file mode 100644 index 5807fb83..00000000 --- a/.sqlx/query-2c63f63f5385c82c3ed8d2a7cd2686becdd314786d2157981ca07bdd5b409a4b.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, description, password_hash, organization_id, role as \"role: Role\", created_at, updated_at\n FROM api_keys\n WHERE organization_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "description", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "password_hash", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "organization_id", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "role: Role", - "type_info": { - "Custom": { - "name": "role", - "kind": { - "Enum": [ - "admin", - "maintainer", - "read_only" - ] - } - } - } - }, - { - "ordinal": 5, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "2c63f63f5385c82c3ed8d2a7cd2686becdd314786d2157981ca07bdd5b409a4b" -} diff --git a/.sqlx/query-2d54476fcbbdf73a5e8d7fd0779bd5f4b671c7a984727770e49cda84e24a7ffe.json b/.sqlx/query-2d54476fcbbdf73a5e8d7fd0779bd5f4b671c7a984727770e49cda84e24a7ffe.json deleted file mode 100644 index 2ff9d8c0..00000000 --- a/.sqlx/query-2d54476fcbbdf73a5e8d7fd0779bd5f4b671c7a984727770e49cda84e24a7ffe.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, description, password_hash, organization_id, role as \"role: Role\", created_at, updated_at\n FROM api_keys\n WHERE id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "description", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "password_hash", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "organization_id", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "role: Role", - "type_info": { - "Custom": { - "name": "role", - "kind": { - "Enum": [ - "admin", - "maintainer", - "read_only" - ] - } - } - } - }, - { - "ordinal": 5, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "2d54476fcbbdf73a5e8d7fd0779bd5f4b671c7a984727770e49cda84e24a7ffe" -} diff --git a/.sqlx/query-3393b6260718d6a22a4d00fe35c36c62e8f676c18e37ea310de793658ea9bb79.json b/.sqlx/query-3393b6260718d6a22a4d00fe35c36c62e8f676c18e37ea310de793658ea9bb79.json new file mode 100644 index 00000000..d095ed82 --- /dev/null +++ b/.sqlx/query-3393b6260718d6a22a4d00fe35c36c62e8f676c18e37ea310de793658ea9bb79.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_keys a\n SET description = $1, role = $2\n FROM organizations o\n WHERE a.organization_id = $3 AND a.id = $4 AND o.id = a.organization_id\n RETURNING a.id, description, password_hash, organization_id,\n o.block_status as \"org_block_status: OrgBlockStatus\",\n role as \"role: Role\",\n a.created_at, a.updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "password_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "organization_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "org_block_status: OrgBlockStatus", + "type_info": { + "Custom": { + "name": "org_block_status", + "kind": { + "Enum": [ + "not_blocked", + "no_sending", + "no_sending_or_receiving", + "full_freeze" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "role: Role", + "type_info": { + "Custom": { + "name": "role", + "kind": { + "Enum": [ + "admin", + "maintainer", + "read_only" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + { + "Custom": { + "name": "role", + "kind": { + "Enum": [ + "admin", + "maintainer", + "read_only" + ] + } + } + }, + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "3393b6260718d6a22a4d00fe35c36c62e8f676c18e37ea310de793658ea9bb79" +} diff --git a/.sqlx/query-10d0da63de04975330348be009f00fa87da9e949d27fb41f693af7f3b2a35eb8.json b/.sqlx/query-41e89cff27c833c5b033cd428abb3bc83762463a19fa024dc285371874b1ec62.json similarity index 62% rename from .sqlx/query-10d0da63de04975330348be009f00fa87da9e949d27fb41f693af7f3b2a35eb8.json rename to .sqlx/query-41e89cff27c833c5b033cd428abb3bc83762463a19fa024dc285371874b1ec62.json index ef5f89ac..4d8fb790 100644 --- a/.sqlx/query-10d0da63de04975330348be009f00fa87da9e949d27fb41f693af7f3b2a35eb8.json +++ b/.sqlx/query-41e89cff27c833c5b033cd428abb3bc83762463a19fa024dc285371874b1ec62.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE api_keys\n SET description = $1, role = $2\n WHERE organization_id = $3 AND id = $4\n RETURNING id, description, password_hash, organization_id, role as \"role: Role\", created_at, updated_at\n ", + "query": "\n SELECT a.id, description, password_hash, organization_id,\n o.block_status as \"org_block_status: OrgBlockStatus\",\n role as \"role: Role\",\n a.created_at, a.updated_at\n FROM api_keys a\n LEFT JOIN organizations o ON o.id = a.organization_id\n WHERE a.id = $1\n ", "describe": { "columns": [ { @@ -25,6 +25,23 @@ }, { "ordinal": 4, + "name": "org_block_status: OrgBlockStatus", + "type_info": { + "Custom": { + "name": "org_block_status", + "kind": { + "Enum": [ + "not_blocked", + "no_sending", + "no_sending_or_receiving", + "full_freeze" + ] + } + } + } + }, + { + "ordinal": 5, "name": "role: Role", "type_info": { "Custom": { @@ -40,32 +57,18 @@ } }, { - "ordinal": 5, + "ordinal": 6, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 7, "name": "updated_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ - "Varchar", - { - "Custom": { - "name": "role", - "kind": { - "Enum": [ - "admin", - "maintainer", - "read_only" - ] - } - } - }, - "Uuid", "Uuid" ] }, @@ -76,8 +79,9 @@ false, false, false, + false, false ] }, - "hash": "10d0da63de04975330348be009f00fa87da9e949d27fb41f693af7f3b2a35eb8" + "hash": "41e89cff27c833c5b033cd428abb3bc83762463a19fa024dc285371874b1ec62" } diff --git a/.sqlx/query-523dd01135b1273f15cd1b4ea62d842d0896040a61af984f6ef292e1d40da557.json b/.sqlx/query-523dd01135b1273f15cd1b4ea62d842d0896040a61af984f6ef292e1d40da557.json index e2c15830..03c28b77 100644 --- a/.sqlx/query-523dd01135b1273f15cd1b4ea62d842d0896040a61af984f6ef292e1d40da557.json +++ b/.sqlx/query-523dd01135b1273f15cd1b4ea62d842d0896040a61af984f6ef292e1d40da557.json @@ -68,7 +68,8 @@ "Enum": [ "not_blocked", "no_sending", - "no_sending_or_receiving" + "no_sending_or_receiving", + "full_freeze" ] } } diff --git a/.sqlx/query-52c638f75bf8945de535857ffe7c3dcf8cdcf8eea0c593bc51401cda35239a76.json b/.sqlx/query-52c638f75bf8945de535857ffe7c3dcf8cdcf8eea0c593bc51401cda35239a76.json deleted file mode 100644 index 6382ca70..00000000 --- a/.sqlx/query-52c638f75bf8945de535857ffe7c3dcf8cdcf8eea0c593bc51401cda35239a76.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO api_users_organizations (api_user_id, organization_id, role)\n VALUES ($1, $2, $3)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - { - "Custom": { - "name": "role", - "kind": { - "Enum": [ - "admin", - "maintainer", - "read_only" - ] - } - } - } - ] - }, - "nullable": [] - }, - "hash": "52c638f75bf8945de535857ffe7c3dcf8cdcf8eea0c593bc51401cda35239a76" -} diff --git a/.sqlx/query-5368e6c5acf2766086c18a97af530da1d300b116f874692f0d37db009b85d568.json b/.sqlx/query-5368e6c5acf2766086c18a97af530da1d300b116f874692f0d37db009b85d568.json index f878ca38..df7ce3e0 100644 --- a/.sqlx/query-5368e6c5acf2766086c18a97af530da1d300b116f874692f0d37db009b85d568.json +++ b/.sqlx/query-5368e6c5acf2766086c18a97af530da1d300b116f874692f0d37db009b85d568.json @@ -33,7 +33,8 @@ "Enum": [ "not_blocked", "no_sending", - "no_sending_or_receiving" + "no_sending_or_receiving", + "full_freeze" ] } } diff --git a/.sqlx/query-56cf9f9ad9747d72f7d36a40dc8cf41c5ada97ee0220337b0adb4f0bfb5931fb.json b/.sqlx/query-56cf9f9ad9747d72f7d36a40dc8cf41c5ada97ee0220337b0adb4f0bfb5931fb.json index f60e71dd..34640199 100644 --- a/.sqlx/query-56cf9f9ad9747d72f7d36a40dc8cf41c5ada97ee0220337b0adb4f0bfb5931fb.json +++ b/.sqlx/query-56cf9f9ad9747d72f7d36a40dc8cf41c5ada97ee0220337b0adb4f0bfb5931fb.json @@ -68,7 +68,8 @@ "Enum": [ "not_blocked", "no_sending", - "no_sending_or_receiving" + "no_sending_or_receiving", + "full_freeze" ] } } diff --git a/.sqlx/query-5a6730de0349b24d679246723c0789ba8d06efd905eae35e96f552f2143c5ba9.json b/.sqlx/query-5a6730de0349b24d679246723c0789ba8d06efd905eae35e96f552f2143c5ba9.json index 1e843225..a3030f39 100644 --- a/.sqlx/query-5a6730de0349b24d679246723c0789ba8d06efd905eae35e96f552f2143c5ba9.json +++ b/.sqlx/query-5a6730de0349b24d679246723c0789ba8d06efd905eae35e96f552f2143c5ba9.json @@ -68,7 +68,8 @@ "Enum": [ "not_blocked", "no_sending", - "no_sending_or_receiving" + "no_sending_or_receiving", + "full_freeze" ] } } diff --git a/.sqlx/query-0ba37b2f107a269ddbdcf66f04a85e5e5bcc8a93005868d133327ae27d68ece8.json b/.sqlx/query-6779d45a84f75d64bc64087f185ad49e3086fb085949de75333cf17da6cbd7c0.json similarity index 66% rename from .sqlx/query-0ba37b2f107a269ddbdcf66f04a85e5e5bcc8a93005868d133327ae27d68ece8.json rename to .sqlx/query-6779d45a84f75d64bc64087f185ad49e3086fb085949de75333cf17da6cbd7c0.json index 420b1ed4..bd4a099c 100644 --- a/.sqlx/query-0ba37b2f107a269ddbdcf66f04a85e5e5bcc8a93005868d133327ae27d68ece8.json +++ b/.sqlx/query-6779d45a84f75d64bc64087f185ad49e3086fb085949de75333cf17da6cbd7c0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n WHERE u.email = $1\n GROUP BY u.id\n ", + "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg(\n (r.organization_id, r.role, o.block_status)::org_role\n )::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations r ON u.id = r.api_user_id\n LEFT JOIN organizations o ON r.organization_id = o.id\n WHERE u.id = $1\n GROUP BY u.id\n ", "describe": { "columns": [ { @@ -53,6 +53,22 @@ } } } + ], + [ + "org_block_status", + { + "Custom": { + "name": "org_block_status", + "kind": { + "Enum": [ + "not_blocked", + "no_sending", + "no_sending_or_receiving", + "full_freeze" + ] + } + } + } ] ] } @@ -101,7 +117,7 @@ ], "parameters": { "Left": [ - "Text" + "Uuid" ] }, "nullable": [ @@ -117,5 +133,5 @@ false ] }, - "hash": "0ba37b2f107a269ddbdcf66f04a85e5e5bcc8a93005868d133327ae27d68ece8" + "hash": "6779d45a84f75d64bc64087f185ad49e3086fb085949de75333cf17da6cbd7c0" } diff --git a/.sqlx/query-3773efed49a9a3471fb8bfc2a9c4dda9d0f79b12ca0e07a925054aa2a2577371.json b/.sqlx/query-68528f35eab8519a9b2236f995f87eb8f419471c4fd75d999e2285aeec137b73.json similarity index 65% rename from .sqlx/query-3773efed49a9a3471fb8bfc2a9c4dda9d0f79b12ca0e07a925054aa2a2577371.json rename to .sqlx/query-68528f35eab8519a9b2236f995f87eb8f419471c4fd75d999e2285aeec137b73.json index 2ea8c9a3..d9ccef9c 100644 --- a/.sqlx/query-3773efed49a9a3471fb8bfc2a9c4dda9d0f79b12ca0e07a925054aa2a2577371.json +++ b/.sqlx/query-68528f35eab8519a9b2236f995f87eb8f419471c4fd75d999e2285aeec137b73.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n GROUP BY u.id\n ORDER BY u.updated_at DESC\n ", + "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg(\n (r.organization_id, r.role, o.block_status)::org_role\n )::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations r ON u.id = r.api_user_id\n LEFT JOIN organizations o ON r.organization_id = o.id\n GROUP BY u.id\n ORDER BY u.updated_at DESC\n ", "describe": { "columns": [ { @@ -53,6 +53,22 @@ } } } + ], + [ + "org_block_status", + { + "Custom": { + "name": "org_block_status", + "kind": { + "Enum": [ + "not_blocked", + "no_sending", + "no_sending_or_receiving", + "full_freeze" + ] + } + } + } ] ] } @@ -115,5 +131,5 @@ false ] }, - "hash": "3773efed49a9a3471fb8bfc2a9c4dda9d0f79b12ca0e07a925054aa2a2577371" + "hash": "68528f35eab8519a9b2236f995f87eb8f419471c4fd75d999e2285aeec137b73" } diff --git a/.sqlx/query-0531daf7468e9201fabcc22bc8a50d1b5d408cdae2f88f795ca283f926ff40a3.json b/.sqlx/query-b1d35676a132fa6078f48f9c00c27004c8050c1b42fecc99b6aed1e779695ac9.json similarity index 65% rename from .sqlx/query-0531daf7468e9201fabcc22bc8a50d1b5d408cdae2f88f795ca283f926ff40a3.json rename to .sqlx/query-b1d35676a132fa6078f48f9c00c27004c8050c1b42fecc99b6aed1e779695ac9.json index ace1128f..46da194b 100644 --- a/.sqlx/query-0531daf7468e9201fabcc22bc8a50d1b5d408cdae2f88f795ca283f926ff40a3.json +++ b/.sqlx/query-b1d35676a132fa6078f48f9c00c27004c8050c1b42fecc99b6aed1e779695ac9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg((o.organization_id,o.role)::org_role)::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations o ON u.id = o.api_user_id\n WHERE github_user_id = $1\n GROUP BY u.id\n ", + "query": "\n SELECT u.id,\n u.email,\n u.name,\n u.github_user_id,\n array_agg(\n (r.organization_id, r.role, o.block_status)::org_role\n )::org_role[] AS \"organization_roles!: Vec\",\n u.global_role AS \"global_role: Role\",\n u.password_hash IS NOT NULL AS \"password_enabled!\",\n u.blocked,\n u.updated_at,\n u.created_at\n FROM api_users u\n LEFT JOIN api_users_organizations r ON u.id = r.api_user_id\n LEFT JOIN organizations o ON r.organization_id = o.id\n WHERE github_user_id = $1\n GROUP BY u.id\n ", "describe": { "columns": [ { @@ -53,6 +53,22 @@ } } } + ], + [ + "org_block_status", + { + "Custom": { + "name": "org_block_status", + "kind": { + "Enum": [ + "not_blocked", + "no_sending", + "no_sending_or_receiving", + "full_freeze" + ] + } + } + } ] ] } @@ -117,5 +133,5 @@ false ] }, - "hash": "0531daf7468e9201fabcc22bc8a50d1b5d408cdae2f88f795ca283f926ff40a3" + "hash": "b1d35676a132fa6078f48f9c00c27004c8050c1b42fecc99b6aed1e779695ac9" } diff --git a/.sqlx/query-f5bf1f3c33751d74671538938f777a52d2a94fe4129b28658c24edddd09f6b58.json b/.sqlx/query-df7e6d2ae740e9fe1b209f084beb805e15fa2cd926a5584a2edff2d3274eb8a3.json similarity index 61% rename from .sqlx/query-f5bf1f3c33751d74671538938f777a52d2a94fe4129b28658c24edddd09f6b58.json rename to .sqlx/query-df7e6d2ae740e9fe1b209f084beb805e15fa2cd926a5584a2edff2d3274eb8a3.json index 22438176..53dc72f3 100644 --- a/.sqlx/query-f5bf1f3c33751d74671538938f777a52d2a94fe4129b28658c24edddd09f6b58.json +++ b/.sqlx/query-df7e6d2ae740e9fe1b209f084beb805e15fa2cd926a5584a2edff2d3274eb8a3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO api_keys (id, description, password_hash, organization_id, role)\n VALUES (gen_random_uuid(), $1, $2, $3, $4)\n RETURNING id, description, password_hash, organization_id, role as \"role: Role\", created_at, updated_at\n ", + "query": "\n SELECT a.id, description, password_hash, organization_id,\n o.block_status as \"org_block_status: OrgBlockStatus\",\n role as \"role: Role\",\n a.created_at, a.updated_at\n FROM api_keys a\n LEFT JOIN organizations o ON o.id = a.organization_id\n WHERE a.organization_id = $1\n ", "describe": { "columns": [ { @@ -25,6 +25,23 @@ }, { "ordinal": 4, + "name": "org_block_status: OrgBlockStatus", + "type_info": { + "Custom": { + "name": "org_block_status", + "kind": { + "Enum": [ + "not_blocked", + "no_sending", + "no_sending_or_receiving", + "full_freeze" + ] + } + } + } + }, + { + "ordinal": 5, "name": "role: Role", "type_info": { "Custom": { @@ -40,33 +57,19 @@ } }, { - "ordinal": 5, + "ordinal": 6, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 7, "name": "updated_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ - "Varchar", - "Varchar", - "Uuid", - { - "Custom": { - "name": "role", - "kind": { - "Enum": [ - "admin", - "maintainer", - "read_only" - ] - } - } - } + "Uuid" ] }, "nullable": [ @@ -76,8 +79,9 @@ false, false, false, + false, false ] }, - "hash": "f5bf1f3c33751d74671538938f777a52d2a94fe4129b28658c24edddd09f6b58" + "hash": "df7e6d2ae740e9fe1b209f084beb805e15fa2cd926a5584a2edff2d3274eb8a3" }