From 4f3938cbc576ed88e97db83e469d17a5a77aa39a Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 14 Oct 2026 18:13:22 +0300 Subject: [PATCH 1/3] Tell the User Service which role a user holds on a deployment GET /api/v1/deployments/{hash}/member-access?user_id=... answers owner/member/viewer/null for the deployment's project, so the User Service can let a Team project member operate the owner's installation (agent install, add-app, redeploy). Service token only, like the other service-to-service routes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f --- ...0_casbin_deployment_member_access.down.sql | 2 + ...000_casbin_deployment_member_access.up.sql | 4 + src/routes/deployment/member_access.rs | 62 ++++++++++ src/routes/deployment/mod.rs | 2 + src/routes/marketplace/public.rs | 2 +- src/startup.rs | 1 + tests/deployment_member_access.rs | 107 ++++++++++++++++++ tests/security_route_matrix.rs | 6 + 8 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 migrations/20261014100000_casbin_deployment_member_access.down.sql create mode 100644 migrations/20261014100000_casbin_deployment_member_access.up.sql create mode 100644 src/routes/deployment/member_access.rs create mode 100644 tests/deployment_member_access.rs diff --git a/migrations/20261014100000_casbin_deployment_member_access.down.sql b/migrations/20261014100000_casbin_deployment_member_access.down.sql new file mode 100644 index 00000000..22e63ae3 --- /dev/null +++ b/migrations/20261014100000_casbin_deployment_member_access.down.sql @@ -0,0 +1,2 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v1 = '/api/v1/deployments/:deployment_hash/member-access'; diff --git a/migrations/20261014100000_casbin_deployment_member_access.up.sql b/migrations/20261014100000_casbin_deployment_member_access.up.sql new file mode 100644 index 00000000..e358e191 --- /dev/null +++ b/migrations/20261014100000_casbin_deployment_member_access.up.sql @@ -0,0 +1,4 @@ +-- Service-to-service: the handler requires STACKER_SERVICE_TOKEN itself. +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_anonymous', '/api/v1/deployments/:deployment_hash/member-access', 'GET', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/src/routes/deployment/member_access.rs b/src/routes/deployment/member_access.rs new file mode 100644 index 00000000..d0c02807 --- /dev/null +++ b/src/routes/deployment/member_access.rs @@ -0,0 +1,62 @@ +//! Which role a user holds on a deployment, for the User Service. +//! +//! Deployments of a shared project are recorded under the project owner, so +//! the User Service installation endpoints only recognise the owner. They ask +//! here before letting a project member operate the server. Roles live only in +//! Stacker (`project_member`), so this keeps them in one place. + +use crate::db; +use crate::helpers::JsonResponse; +use crate::routes::marketplace::public::require_stacker_service_auth; +use crate::routes::project::access::{access_of, Access}; +use actix_web::{get, web, HttpRequest, Responder, Result}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +#[derive(Debug, Deserialize)] +pub struct MemberAccessQuery { + pub user_id: String, +} + +#[derive(Debug, Serialize)] +struct MemberAccess { + /// "owner", "member", "viewer", or null when the user has no access or + /// the deployment is unknown. + role: Option<&'static str>, +} + +#[tracing::instrument(name = "Deployment member access", skip_all)] +#[get("/{deployment_hash}/member-access")] +pub async fn member_access_handler( + req: HttpRequest, + path: web::Path<(String,)>, + query: web::Query, + pg_pool: web::Data, +) -> Result { + require_stacker_service_auth(&req)?; + + let deployment = db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), &path.0) + .await + .map_err(JsonResponse::internal_server_error)?; + let project = match deployment { + Some(deployment) => db::project::fetch(pg_pool.get_ref(), deployment.project_id) + .await + .map_err(JsonResponse::internal_server_error)?, + None => None, + }; + let access = match project { + Some(project) => access_of(pg_pool.get_ref(), &project, &query.user_id) + .await + .map_err(JsonResponse::internal_server_error)?, + None => None, + }; + let role = access.map(|access| match access { + Access::Owner => "owner", + Access::Member => "member", + Access::Viewer => "viewer", + }); + + Ok(JsonResponse::build() + .set_item(MemberAccess { role }) + .ok("OK")) +} diff --git a/src/routes/deployment/mod.rs b/src/routes/deployment/mod.rs index 95a6b675..7308abf3 100644 --- a/src/routes/deployment/mod.rs +++ b/src/routes/deployment/mod.rs @@ -1,6 +1,7 @@ pub mod capabilities; pub mod events; pub mod force_complete; +pub mod member_access; pub mod plan; pub mod state; pub mod status; @@ -8,6 +9,7 @@ pub mod status; pub use capabilities::*; pub use events::*; pub use force_complete::*; +pub use member_access::*; pub use plan::*; pub use state::*; pub use status::*; diff --git a/src/routes/marketplace/public.rs b/src/routes/marketplace/public.rs index bd08b369..ff5f0bd9 100644 --- a/src/routes/marketplace/public.rs +++ b/src/routes/marketplace/public.rs @@ -215,7 +215,7 @@ struct DeployCompleteResponse { deploy_count_incremented: bool, } -fn require_stacker_service_auth(req: &HttpRequest) -> Result<()> { +pub(crate) fn require_stacker_service_auth(req: &HttpRequest) -> Result<()> { let expected_token = std::env::var("STACKER_SERVICE_TOKEN").unwrap_or_default(); if expected_token.trim().is_empty() { return Err(JsonResponse::::build() diff --git a/src/startup.rs b/src/startup.rs index e6d09c6f..54ef83ce 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -389,6 +389,7 @@ pub async fn run( .service( web::scope("/v1/deployments") .service(routes::deployment::capabilities_handler) + .service(routes::deployment::member_access_handler) .service(routes::deployment::events_handler) .service(routes::deployment::list_handler) .service(routes::deployment::plan_handler) diff --git a/tests/deployment_member_access.rs b/tests/deployment_member_access.rs new file mode 100644 index 00000000..97453a1d --- /dev/null +++ b/tests/deployment_member_access.rs @@ -0,0 +1,107 @@ +//! The User Service asks Stacker which role a user holds on a deployment, so +//! a project member can operate the server (agent, apps, redeploy) through the +//! User Service installation endpoints, which only know the owner. +//! +//! The route is service-to-service: it answers only with STACKER_SERVICE_TOKEN +//! in `x-stacker-service-token`. + +mod common; + +use serde_json::Value; + +const SERVICE_TOKEN: &str = "test-stacker-service-token"; + +async fn role_of(address: &str, hash: &str, user_id: &str, token: Option<&str>) -> (u16, Value) { + let mut req = reqwest::Client::new().get(format!( + "{address}/api/v1/deployments/{hash}/member-access?user_id={user_id}" + )); + if let Some(token) = token { + req = req.header("x-stacker-service-token", token); + } + let resp = req.send().await.expect("request failed"); + let status = resp.status().as_u16(); + let text = resp.text().await.unwrap_or_default(); + ( + status, + serde_json::from_str(&text).unwrap_or(Value::String(text)), + ) +} + +async fn add_member(pool: &sqlx::PgPool, project_id: i32, user_id: &str, role: &str) { + sqlx::query( + "INSERT INTO project_member (project_id, user_id, role, created_by) VALUES ($1, $2, $3, $4)", + ) + .bind(project_id) + .bind(user_id) + .bind(role) + .bind(common::USER_A_ID) + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn the_user_service_learns_each_users_role_on_a_deployment() { + std::env::set_var("STACKER_SERVICE_TOKEN", SERVICE_TOKEN); + let Some(app) = common::spawn_app_two_users().await else { + return; + }; + let pool = &app.db_pool; + let project = common::create_test_project(pool, common::USER_A_ID).await; + let hash = format!("member-access-{}", uuid::Uuid::new_v4().simple()); + common::create_test_deployment(pool, common::USER_A_ID, project, &hash).await; + add_member(pool, project, "member_user", "member").await; + add_member(pool, project, "viewer_user", "viewer").await; + + for (user, expected) in [ + (common::USER_A_ID, Value::from("owner")), + ("member_user", Value::from("member")), + ("viewer_user", Value::from("viewer")), + ("someone_else", Value::Null), + ] { + let (status, body) = role_of(&app.address, &hash, user, Some(SERVICE_TOKEN)).await; + assert_eq!(status, 200, "{user}: {body}"); + assert_eq!(body["item"]["role"], expected, "{user}: {body}"); + } + + // An unknown deployment gives no role rather than an error. + let (status, body) = role_of( + &app.address, + "no-such-hash", + "member_user", + Some(SERVICE_TOKEN), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["item"]["role"], Value::Null); +} + +#[tokio::test] +async fn only_the_user_service_may_ask() { + std::env::set_var("STACKER_SERVICE_TOKEN", SERVICE_TOKEN); + let Some(app) = common::spawn_app_two_users().await else { + return; + }; + let pool = &app.db_pool; + let project = common::create_test_project(pool, common::USER_A_ID).await; + let hash = format!("member-access-{}", uuid::Uuid::new_v4().simple()); + common::create_test_deployment(pool, common::USER_A_ID, project, &hash).await; + + let (status, _) = role_of(&app.address, &hash, common::USER_A_ID, None).await; + assert_eq!(status, 403, "no service token"); + let (status, _) = role_of(&app.address, &hash, common::USER_A_ID, Some("wrong")).await; + assert_eq!(status, 403, "wrong service token"); + + // A signed-in user cannot ask about anyone either. + let resp = reqwest::Client::new() + .get(format!( + "{}/api/v1/deployments/{hash}/member-access?user_id={}", + app.address, + common::USER_A_ID + )) + .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 403, "user session"); +} diff --git a/tests/security_route_matrix.rs b/tests/security_route_matrix.rs index 4e9a1432..ac11c84b 100644 --- a/tests/security_route_matrix.rs +++ b/tests/security_route_matrix.rs @@ -264,6 +264,12 @@ const ROUTES: &[(&str, &str, Kind, &str)] = &[ Token, "internal service key", ), + ( + "GET", + "/api/v1/deployments/{deployment_hash}/member-access", + Token, + "internal service key; a user's role for the User Service", + ), ( "POST", "/api/v1/marketplace/payouts/webhook", From 56c351b6e0bebcab0c57f738e6a2786d9dd423a4 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 14 Oct 2026 18:22:54 +0300 Subject: [PATCH 2/3] Report a member's deployment to the member as well as the owner A project member deploys as the owner, so the install payload carried only the owner's address and the member who started the deployment never got the report. The payload now carries initiator_email for deploy, deploy with a saved cloud and rollback by a member. It is set by the server only: Payload is read from client-supplied project metadata, so the field is never deserialized. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f --- src/connectors/install_service/client.rs | 2 ++ src/connectors/install_service/mock.rs | 1 + src/connectors/install_service/mod.rs | 1 + src/forms/project/deploy.rs | 5 ++++ src/forms/project/payload.rs | 31 ++++++++++++++++++++++++ src/routes/project/access.rs | 25 +++++++++++++++++++ src/routes/project/deploy.rs | 10 ++++++-- 7 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/connectors/install_service/client.rs b/src/connectors/install_service/client.rs index 695ce92d..d0919cae 100644 --- a/src/connectors/install_service/client.rs +++ b/src/connectors/install_service/client.rs @@ -107,6 +107,7 @@ impl InstallServiceConnector for InstallServiceClient { server_public_key: Option, server_private_key: Option, proxy_domains: Option, + initiator_email: Option, ) -> Result { // Build payload for the install service let mut payload = crate::forms::project::Payload::try_from(project) @@ -140,6 +141,7 @@ impl InstallServiceConnector for InstallServiceClient { payload.common_domain = stack_var_string(&form_stack.vars, "commonDomain"); } payload.user_token = Some(user_id); + payload.initiator_email = initiator_email.filter(|email| *email != user_email); payload.user_email = Some(user_email); payload.docker_compose = Some(compress(fc.as_str())); payload.registry = registry; diff --git a/src/connectors/install_service/mock.rs b/src/connectors/install_service/mock.rs index 12fd793d..fe1295c3 100644 --- a/src/connectors/install_service/mock.rs +++ b/src/connectors/install_service/mock.rs @@ -27,6 +27,7 @@ impl InstallServiceConnector for MockInstallServiceConnector { _server_public_key: Option, _server_private_key: Option, _proxy_domains: Option, + _initiator_email: Option, ) -> Result { Ok(project_id) } diff --git a/src/connectors/install_service/mod.rs b/src/connectors/install_service/mod.rs index a9a70d9b..1a9eb38f 100644 --- a/src/connectors/install_service/mod.rs +++ b/src/connectors/install_service/mod.rs @@ -53,6 +53,7 @@ pub trait InstallServiceConnector: Send + Sync { server_public_key: Option, server_private_key: Option, proxy_domains: Option, + initiator_email: Option, ) -> Result; async fn configure_cloud_firewall( diff --git a/src/forms/project/deploy.rs b/src/forms/project/deploy.rs index c0d8db4e..8d403701 100644 --- a/src/forms/project/deploy.rs +++ b/src/forms/project/deploy.rs @@ -88,6 +88,11 @@ pub struct Deploy { /// not use this. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) proxy_domains: Option, + + /// Set by the server when a project member deploys as the owner, never + /// read from the request. + #[serde(skip)] + pub(crate) initiator_email: Option, } impl std::fmt::Debug for Deploy { diff --git a/src/forms/project/payload.rs b/src/forms/project/payload.rs index d948491c..16e91ed8 100644 --- a/src/forms/project/payload.rs +++ b/src/forms/project/payload.rs @@ -13,6 +13,11 @@ pub struct Payload { pub(crate) deployment_hash: Option, pub(crate) user_token: Option, pub(crate) user_email: Option, + /// A project member who started this deployment as the owner; the install + /// service reports to them as well. Set by the server only: project + /// metadata comes from the client, so it is never read from there. + #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")] + pub(crate) initiator_email: Option, #[serde( default, rename = "commonDomain", @@ -223,4 +228,30 @@ mod tests { "proxy_domains must be omitted when unset" ); } + + #[test] + fn initiator_email_is_sent_but_never_read_from_project_metadata() { + // A member deploys as the owner; the install service also reports to + // the member. Project metadata comes from the client, so an address + // there must not become a report recipient. + let project = models::Project::new( + "owner-1".to_string(), + "team-stack".to_string(), + json!({ + "stack": {"stack_code": "team-stack"}, + "custom": {"web": [], "custom_stack_code": "team-stack"}, + "initiator_email": "someone@example.com" + }), + json!({}), + ); + let mut payload = Payload::try_from(&project).expect("payload"); + assert_eq!(payload.initiator_email, None); + + let empty = serde_json::to_value(&payload).expect("serialize"); + assert!(empty.get("initiator_email").is_none()); + + payload.initiator_email = Some("member@example.com".to_string()); + let serialized = serde_json::to_value(&payload).expect("serialize"); + assert_eq!(serialized["initiator_email"], json!("member@example.com")); + } } diff --git a/src/routes/project/access.rs b/src/routes/project/access.rs index fda29557..d7dfb27e 100644 --- a/src/routes/project/access.rs +++ b/src/routes/project/access.rs @@ -180,6 +180,12 @@ pub async fn acting_user( }) } +/// The member's address when a member deploys as the owner, so the deployment +/// reports reach them as well as the owner. +pub fn initiator_email(user: &models::User, access: Access) -> Option { + (access != Access::Owner).then(|| user.email.clone()) +} + /// The owner's address from the User Service internal user lookup. async fn owner_email(user_service_url: &str, owner_id: &str) -> Result { let key = std::env::var("INTERNAL_SERVICES_ACCESS_KEY").unwrap_or_default(); @@ -217,6 +223,25 @@ async fn owner_email(user_service_url: &str, owner_id: &str) -> Result::build().internal_server_error(err))?; @@ -1856,6 +1857,8 @@ pub async fn item( "not found", ) .await?; + let mut form = form.into_inner(); + form.initiator_email = access::initiator_email(&user, access); let user = Arc::new( access::acting_user(&project, user.as_ref(), access, &sets.user_service_url).await?, ); @@ -1863,7 +1866,7 @@ pub async fn item( let (project_id, deployment_id) = deploy_project( user.as_ref(), project, - form.into_inner(), + form, pg_pool.get_ref(), mq_manager.get_ref(), sets.get_ref(), @@ -1917,6 +1920,7 @@ pub async fn saved_item( "Project not found", ) .await?; + form.initiator_email = access::initiator_email(&user, access); let user = Arc::new( access::acting_user(&project, user.as_ref(), access, &sets.user_service_url).await?, ); @@ -2155,6 +2159,7 @@ pub async fn rollback( "Project not found", ) .await?; + let initiator_email = access::initiator_email(&user, access); let user = Arc::new( access::acting_user(&project, user.as_ref(), access, &sets.user_service_url).await?, ); @@ -2227,7 +2232,8 @@ pub async fn rollback( let (metadata, template_stack_code) = build_rollback_project_payload(target_version.stack_definition.clone()) .map_err(|msg| JsonResponse::::build().bad_request(msg))?; - let deploy_form = build_rollback_deploy_form(template_stack_code); + let mut deploy_form = build_rollback_deploy_form(template_stack_code); + deploy_form.initiator_email = initiator_email; validate_reused_cloud_server(&cloud, &server) .await From aeae0c5d69a380dc1cdf23613bb6f948f3c02648 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 14 Oct 2026 19:19:47 +0300 Subject: [PATCH 3/3] Let the owner decide whether members get server access The deployment report carries the server's SSH key and passwords. A member who deploys now gets it only when the owner turned on server access for members: project.members_server_access, off by default, set through PATCH /project/{id}/server-access (owner only) and shown in /seats. Without it the report goes to the owner only, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f --- ...000_project_members_server_access.down.sql | 2 + ...20000_project_members_server_access.up.sql | 7 ++ src/db/project.rs | 31 +++++ src/routes/project/access.rs | 20 ++++ src/routes/project/deploy.rs | 9 +- src/routes/project/invitation.rs | 5 + src/routes/project/member.rs | 24 +++- src/startup.rs | 1 + tests/project_team_seats.rs | 106 ++++++++++++++++++ tests/security_project_routes.rs | 3 + 10 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 migrations/20261014120000_project_members_server_access.down.sql create mode 100644 migrations/20261014120000_project_members_server_access.up.sql diff --git a/migrations/20261014120000_project_members_server_access.down.sql b/migrations/20261014120000_project_members_server_access.down.sql new file mode 100644 index 00000000..7a0efcec --- /dev/null +++ b/migrations/20261014120000_project_members_server_access.down.sql @@ -0,0 +1,2 @@ +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v1 IN ('/project/:id/server-access', '/api/v1/project/:id/server-access'); +ALTER TABLE project DROP COLUMN IF EXISTS members_server_access; diff --git a/migrations/20261014120000_project_members_server_access.up.sql b/migrations/20261014120000_project_members_server_access.up.sql new file mode 100644 index 00000000..e80d955a --- /dev/null +++ b/migrations/20261014120000_project_members_server_access.up.sql @@ -0,0 +1,7 @@ +-- Team seats: the owner decides whether members get the servers' SSH key and +-- passwords (the deployment report). Off by default. +ALTER TABLE project ADD COLUMN IF NOT EXISTS members_server_access BOOLEAN NOT NULL DEFAULT FALSE; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id/server-access', 'PATCH', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/project/:id/server-access', 'PATCH', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/project/:id/server-access', 'PATCH', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/project/:id/server-access', 'PATCH', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/src/db/project.rs b/src/db/project.rs index 6292ea64..45eda422 100644 --- a/src/db/project.rs +++ b/src/db/project.rs @@ -247,6 +247,37 @@ pub async fn set_protected( }) } +/// Whether the owner lets project members get the servers' SSH key and +/// passwords (the deployment report). +pub async fn members_server_access(pool: &PgPool, id: i32) -> Result { + sqlx::query_scalar::<_, bool>("SELECT members_server_access FROM project WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await + .map(|flag| flag.unwrap_or(false)) + .map_err(|err| { + tracing::error!("Failed to read members_server_access: {:?}", err); + "Failed to read project".to_string() + }) +} + +pub async fn set_members_server_access( + pool: &PgPool, + id: i32, + enabled: bool, +) -> Result { + sqlx::query("UPDATE project SET members_server_access = $2 WHERE id = $1") + .bind(id) + .bind(enabled) + .execute(pool) + .await + .map(|r| r.rows_affected() > 0) + .map_err(|err| { + tracing::error!("Failed to set members_server_access: {:?}", err); + "Failed to update project".to_string() + }) +} + /// Summary of resources that block project deletion. #[derive(Debug, Clone, serde::Serialize)] pub struct DeletionBlockers { diff --git a/src/routes/project/access.rs b/src/routes/project/access.rs index d7dfb27e..7bcb1354 100644 --- a/src/routes/project/access.rs +++ b/src/routes/project/access.rs @@ -186,6 +186,26 @@ pub fn initiator_email(user: &models::User, access: Access) -> Option { (access != Access::Owner).then(|| user.email.clone()) } +/// The member to send the deployment report to as well as the owner, when the +/// owner turned on server access for members. The report carries the server's +/// SSH key and passwords, so a lookup failure means no. +pub async fn report_initiator( + pool: &PgPool, + project: &models::Project, + user: &models::User, + access: Access, +) -> Option { + initiator_email(user, access)?; + match db::project::members_server_access(pool, project.id).await { + Ok(true) => initiator_email(user, access), + Ok(false) => None, + Err(err) => { + tracing::warn!("Server access lookup failed, the report goes to the owner only: {err}"); + None + } + } +} + /// The owner's address from the User Service internal user lookup. async fn owner_email(user_service_url: &str, owner_id: &str) -> Result { let key = std::env::var("INTERNAL_SERVICES_ACCESS_KEY").unwrap_or_default(); diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index 9b4223d4..9d52e500 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -1858,7 +1858,8 @@ pub async fn item( ) .await?; let mut form = form.into_inner(); - form.initiator_email = access::initiator_email(&user, access); + form.initiator_email = + access::report_initiator(pg_pool.get_ref(), &project, &user, access).await; let user = Arc::new( access::acting_user(&project, user.as_ref(), access, &sets.user_service_url).await?, ); @@ -1920,7 +1921,8 @@ pub async fn saved_item( "Project not found", ) .await?; - form.initiator_email = access::initiator_email(&user, access); + form.initiator_email = + access::report_initiator(pg_pool.get_ref(), &project, &user, access).await; let user = Arc::new( access::acting_user(&project, user.as_ref(), access, &sets.user_service_url).await?, ); @@ -2159,7 +2161,8 @@ pub async fn rollback( "Project not found", ) .await?; - let initiator_email = access::initiator_email(&user, access); + let initiator_email = + access::report_initiator(pg_pool.get_ref(), &project, &user, access).await; let user = Arc::new( access::acting_user(&project, user.as_ref(), access, &sets.user_service_url).await?, ); diff --git a/src/routes/project/invitation.rs b/src/routes/project/invitation.rs index 83d7656a..a40ee40d 100644 --- a/src/routes/project/invitation.rs +++ b/src/routes/project/invitation.rs @@ -311,6 +311,7 @@ struct Seats { seats_used: i64, seats_free: i64, can_invite: bool, + members_server_access: bool, } #[tracing::instrument(name = "Project seats", skip_all)] @@ -336,6 +337,9 @@ pub async fn seats( .await .map_err(|err| JsonResponse::internal_server_error(err.to_string()))?; + let members_server_access = db::project::members_server_access(pg_pool.get_ref(), project.id) + .await + .map_err(JsonResponse::internal_server_error)?; let free = (PROJECT_SEATS - used).max(0); let on_team = matches!(plan_code(settings.get_ref(), &user).await, Ok(Some(code)) if can_share_projects(&code)); Ok(JsonResponse::build() @@ -344,6 +348,7 @@ pub async fn seats( seats_used: used, seats_free: free, can_invite: on_team && free > 0, + members_server_access, }) .ok("OK")) } diff --git a/src/routes/project/member.rs b/src/routes/project/member.rs index 01ed8835..4ce082fa 100644 --- a/src/routes/project/member.rs +++ b/src/routes/project/member.rs @@ -4,7 +4,7 @@ use crate::helpers::JsonResponse; use crate::models; use crate::routes::project::access::{self, Need}; use crate::routes::project::invitation::{require_team_plan, reserve_seat, INVITABLE_ROLES}; -use actix_web::{delete, get, post, web, HttpResponse, Responder, Result}; +use actix_web::{delete, get, patch, post, web, HttpResponse, Responder, Result}; use serde::Deserialize; use sqlx::PgPool; use std::sync::Arc; @@ -87,6 +87,28 @@ pub async fn add( Ok(JsonResponse::build().set_item(member).ok("OK")) } +#[derive(Debug, Deserialize)] +pub struct ServerAccessForm { + pub enabled: bool, +} + +/// Owner only: whether members get the servers' SSH key and passwords in the +/// deployment report. +#[tracing::instrument(name = "Set members server access", skip_all)] +#[patch("/{id}/server-access")] +pub async fn set_server_access( + user: web::ReqData>, + path: web::Path<(i32,)>, + web::Json(form): web::Json, + pg_pool: web::Data, +) -> Result { + let project = fetch_owned_project(pg_pool.get_ref(), path.0, &user.id).await?; + db::project::set_members_server_access(pg_pool.get_ref(), project.id, form.enabled) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + Ok(JsonResponse::::build().ok("OK")) +} + #[tracing::instrument(name = "List project members", skip_all)] #[get("/{id}/members")] pub async fn list( diff --git a/src/startup.rs b/src/startup.rs index 54ef83ce..b1fc1513 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -42,6 +42,7 @@ fn project_scope(path: &str) -> actix_web::Scope { .service(crate::routes::project::sync::item) .service(crate::routes::project::delete::item) .service(crate::routes::project::protection::toggle) + .service(crate::routes::project::member::set_server_access) .service(crate::routes::project::app::list_apps) .service(crate::routes::project::app::create_app) .service(crate::routes::project::app::get_app) diff --git a/tests/project_team_seats.rs b/tests/project_team_seats.rs index 2f663b74..ad275b85 100644 --- a/tests/project_team_seats.rs +++ b/tests/project_team_seats.rs @@ -224,6 +224,7 @@ impl App { "GET" => self.client.get(&url), "POST" => self.client.post(&url), "PUT" => self.client.put(&url), + "PATCH" => self.client.patch(&url), "DELETE" => self.client.delete(&url), other => panic!("unsupported verb {other}"), }; @@ -1372,3 +1373,108 @@ async fn a_viewer_and_an_outsider_do_not_get_the_owners_clouds() { .await; assert_eq!(status, 404, "outsider"); } + +// ── Server access for members ── +// +// The deployment report carries the server's SSH key and passwords. A member +// who deploys gets it only when the owner turned on server access for members. + +async fn server_access(app: &App, project: i32) -> bool { + let (status, body) = app + .call( + Some(TEAM), + "GET", + &format!("/project/{project}/seats"), + None, + ) + .await; + assert_eq!(status, 200, "seats: {body}"); + body["item"]["members_server_access"].as_bool().unwrap() +} + +#[tokio::test] +async fn the_owner_decides_whether_members_get_server_access() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + app.add_member(project, COLLEAGUE.id, "member").await; + assert!(!server_access(&app, project).await, "off by default"); + + let route = format!("/project/{project}/server-access"); + let (status, body) = app + .call( + Some(TEAM), + "PATCH", + &route, + Some(json!({ "enabled": true })), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert!(server_access(&app, project).await); + + let (status, _) = app + .call( + Some(COLLEAGUE), + "PATCH", + &route, + Some(json!({ "enabled": false })), + ) + .await; + assert_eq!(status, 403, "a member cannot change it"); + assert!(server_access(&app, project).await); + + let (status, _) = app + .call( + Some(TEAM), + "PATCH", + &route, + Some(json!({ "enabled": false })), + ) + .await; + assert_eq!(status, 200); + assert!(!server_access(&app, project).await); +} + +#[tokio::test] +async fn a_member_gets_the_deployment_report_only_with_server_access() { + use stacker::routes::access::{report_initiator, Access}; + + let Some(app) = App::spawn().await else { + return; + }; + let project_id = app.project(TEAM).await; + let project = stacker::db::project::fetch(&app.pool, project_id) + .await + .unwrap() + .unwrap(); + let member = stacker::models::User { + id: COLLEAGUE.id.to_string(), + first_name: String::new(), + last_name: String::new(), + email: COLLEAGUE.email.to_string(), + role: "group_user".to_string(), + email_confirmed: true, + mfa_verified: false, + access_token: None, + }; + + assert_eq!( + report_initiator(&app.pool, &project, &member, Access::Member).await, + None + ); + + stacker::db::project::set_members_server_access(&app.pool, project_id, true) + .await + .unwrap(); + assert_eq!( + report_initiator(&app.pool, &project, &member, Access::Member) + .await + .as_deref(), + Some(COLLEAGUE.email) + ); + assert_eq!( + report_initiator(&app.pool, &project, &member, Access::Owner).await, + None + ); +} diff --git a/tests/security_project_routes.rs b/tests/security_project_routes.rs index 271e0041..e56b01ca 100644 --- a/tests/security_project_routes.rs +++ b/tests/security_project_routes.rs @@ -57,6 +57,7 @@ const ROUTES: &[(&str, &str)] = &[ ("GET", "/{id}/invitations"), ("POST", "/{id}/invitations"), ("GET", "/{id}/seats"), + ("PATCH", "/{id}/server-access"), ("GET", "/{id}/clouds"), // Destructive ones last, so a missing check cannot hide the others. ("DELETE", "/{id}/apps/{code}"), @@ -107,6 +108,7 @@ fn body_for(method: &str, path: &str) -> Option { json!({ "custom": { "custom_stack_code": "intruder" } }) } ("PATCH", "/{id}/protection") => json!({ "is_protected": false }), + ("PATCH", "/{id}/server-access") => json!({ "enabled": true }), _ => return None, }; Some(body) @@ -397,6 +399,7 @@ const NEEDS: &[(&str, &str, Need)] = &[ ("GET", "/{id}/invitations", Need::Owner), ("POST", "/{id}/invitations", Need::Owner), ("GET", "/{id}/seats", Need::Owner), + ("PATCH", "/{id}/server-access", Need::Owner), ("GET", "/{id}/clouds", Need::Manage), ("DELETE", "/{id}/apps/{code}", Need::Manage), ("DELETE", "/{id}/invitations/{invitation_id}", Need::Owner),