Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DELETE FROM public.casbin_rule
WHERE ptype = 'p' AND v1 = '/api/v1/deployments/:deployment_hash/member-access';
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions src/connectors/install_service/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ impl InstallServiceConnector for InstallServiceClient {
server_public_key: Option<String>,
server_private_key: Option<String>,
proxy_domains: Option<serde_json::Value>,
initiator_email: Option<String>,
) -> Result<i32, String> {
// Build payload for the install service
let mut payload = crate::forms::project::Payload::try_from(project)
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/connectors/install_service/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ impl InstallServiceConnector for MockInstallServiceConnector {
_server_public_key: Option<String>,
_server_private_key: Option<String>,
_proxy_domains: Option<serde_json::Value>,
_initiator_email: Option<String>,
) -> Result<i32, String> {
Ok(project_id)
}
Expand Down
1 change: 1 addition & 0 deletions src/connectors/install_service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub trait InstallServiceConnector: Send + Sync {
server_public_key: Option<String>,
server_private_key: Option<String>,
proxy_domains: Option<serde_json::Value>,
initiator_email: Option<String>,
) -> Result<i32, String>;

async fn configure_cloud_firewall(
Expand Down
31 changes: 31 additions & 0 deletions src/db/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, String> {
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<bool, String> {
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 {
Expand Down
5 changes: 5 additions & 0 deletions src/forms/project/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ pub struct Deploy {
/// not use this.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) proxy_domains: Option<Value>,

/// Set by the server when a project member deploys as the owner, never
/// read from the request.
#[serde(skip)]
pub(crate) initiator_email: Option<String>,
}

impl std::fmt::Debug for Deploy {
Expand Down
31 changes: 31 additions & 0 deletions src/forms/project/payload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ pub struct Payload {
pub(crate) deployment_hash: Option<String>,
pub(crate) user_token: Option<String>,
pub(crate) user_email: Option<String>,
/// 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<String>,
#[serde(
default,
rename = "commonDomain",
Expand Down Expand Up @@ -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"));
}
}
62 changes: 62 additions & 0 deletions src/routes/deployment/member_access.rs
Original file line number Diff line number Diff line change
@@ -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<MemberAccessQuery>,
pg_pool: web::Data<PgPool>,
) -> Result<impl Responder> {
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"))
}
2 changes: 2 additions & 0 deletions src/routes/deployment/mod.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
pub mod capabilities;
pub mod events;
pub mod force_complete;
pub mod member_access;
pub mod plan;
pub mod state;
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::*;
2 changes: 1 addition & 1 deletion src/routes/marketplace/public.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Value>::build()
Expand Down
45 changes: 45 additions & 0 deletions src/routes/project/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
(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<String> {
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<String, String> {
let key = std::env::var("INTERNAL_SERVICES_ACCESS_KEY").unwrap_or_default();
Expand Down Expand Up @@ -217,6 +243,25 @@ async fn owner_email(user_service_url: &str, owner_id: &str) -> Result<String, S
mod tests {
use super::*;

#[test]
fn only_a_member_deploy_has_a_separate_initiator() {
let member = models::User {
id: "member-1".to_string(),
first_name: String::new(),
last_name: String::new(),
email: "member@example.com".to_string(),
role: "group_user".to_string(),
email_confirmed: true,
mfa_verified: false,
access_token: None,
};
assert_eq!(
initiator_email(&member, Access::Member).as_deref(),
Some("member@example.com")
);
assert_eq!(initiator_email(&member, Access::Owner), None);
}

#[test]
fn roles_allow_what_the_team_decisions_say() {
use Access::*;
Expand Down
13 changes: 11 additions & 2 deletions src/routes/project/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,7 @@ async fn execute_deployment(
new_public_key,
new_private_key,
form.proxy_domains.clone(),
form.initiator_email.clone(),
)
.await
.map_err(|err| JsonResponse::<models::Project>::build().internal_server_error(err))?;
Expand Down Expand Up @@ -1856,14 +1857,17 @@ 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?,
);

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(),
Expand Down Expand Up @@ -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?,
);
Expand Down Expand Up @@ -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?,
);
Expand Down Expand Up @@ -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::<models::Project>::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
Expand Down
5 changes: 5 additions & 0 deletions src/routes/project/invitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand 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()
Expand All @@ -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"))
}
Expand Down
Loading
Loading