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/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/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/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/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/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/routes/project/access.rs b/src/routes/project/access.rs index fda29557..7bcb1354 100644 --- a/src/routes/project/access.rs +++ b/src/routes/project/access.rs @@ -180,6 +180,32 @@ 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 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(); @@ -217,6 +243,25 @@ async fn owner_email(user_service_url: &str, owner_id: &str) -> Result::build().internal_server_error(err))?; @@ -1856,6 +1857,9 @@ pub async fn item( "not found", ) .await?; + let mut form = form.into_inner(); + 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?, ); @@ -1863,7 +1867,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 +1921,8 @@ pub async fn saved_item( "Project not found", ) .await?; + 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?, ); @@ -2155,6 +2161,8 @@ pub async fn rollback( "Project not found", ) .await?; + 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?, ); @@ -2227,7 +2235,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 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 e6d09c6f..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) @@ -389,6 +390,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/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), 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",