fix: enforce repository access and cache boundaries
This commit is contained in:
@@ -129,6 +129,7 @@ pub struct RepositorySshMaterializationAccess {
|
|||||||
pub host_trust_id: String,
|
pub host_trust_id: String,
|
||||||
pub host_trust_revision: u64,
|
pub host_trust_revision: u64,
|
||||||
pub access: workspace_api::RepositoryAccessMode,
|
pub access: workspace_api::RepositoryAccessMode,
|
||||||
|
pub expires_at_epoch_seconds: u64,
|
||||||
pub private_key: SensitiveString,
|
pub private_key: SensitiveString,
|
||||||
pub known_hosts_entry: SensitiveString,
|
pub known_hosts_entry: SensitiveString,
|
||||||
}
|
}
|
||||||
@@ -146,6 +147,12 @@ pub struct RepositoryMaterializationContext {
|
|||||||
pub ssh: Option<RepositorySshMaterializationAccess>,
|
pub ssh: Option<RepositorySshMaterializationAccess>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WorkingDirectoryRepositoryAccessRequest {
|
||||||
|
pub working_directory_id: String,
|
||||||
|
pub materialization: RepositoryMaterializationContext,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkingDirectoryRequest {
|
pub struct WorkingDirectoryRequest {
|
||||||
pub repository: WorkingDirectoryRepository,
|
pub repository: WorkingDirectoryRepository,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::catalog::{WorkingDirectoryRequest, WorkingDirectoryStatus};
|
use crate::catalog::{
|
||||||
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
|
};
|
||||||
use crate::config_bundle::ConfigBundle;
|
use crate::config_bundle::ConfigBundle;
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
use crate::identity::WorkerRef;
|
use crate::identity::WorkerRef;
|
||||||
@@ -319,6 +321,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
_request: &WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_repository_access_unsupported",
|
||||||
|
"Worker execution backend does not support Repository access authorization",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -454,6 +466,14 @@ impl WorkerExecutionBackendRef {
|
|||||||
self.backend.create_working_directory(request)
|
self.backend.create_working_directory(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
self.backend
|
||||||
|
.authorize_working_directory_repository_access(request)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
self.backend.list_working_directories()
|
self.backend.list_working_directories()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ use crate::auth::{
|
|||||||
};
|
};
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
||||||
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
|
WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
@@ -203,6 +204,10 @@ fn runtime_http_router_with_optional_auth(
|
|||||||
"/v1/working-directories",
|
"/v1/working-directories",
|
||||||
get(list_working_directories).post(create_working_directory),
|
get(list_working_directories).post(create_working_directory),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/working-directories/repository-access",
|
||||||
|
post(authorize_working_directory_repository_access),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/v1/working-directories/{working_directory_id}/sessions",
|
"/v1/working-directories/{working_directory_id}/sessions",
|
||||||
post(open_workdir_session),
|
post(open_workdir_session),
|
||||||
@@ -335,6 +340,11 @@ pub struct RuntimeHttpWorkingDirectoriesResponse {
|
|||||||
pub working_directories: Vec<WorkingDirectoryStatus>,
|
pub working_directories: Vec<WorkingDirectoryStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeHttpRepositoryAccessResponse {
|
||||||
|
pub authorized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Working directory response used by create/detail/delete endpoints.
|
/// Working directory response used by create/detail/delete endpoints.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeHttpWorkingDirectoryResponse {
|
pub struct RuntimeHttpWorkingDirectoryResponse {
|
||||||
@@ -513,6 +523,28 @@ async fn list_workers(
|
|||||||
Ok(Json(RuntimeHttpWorkersResponse { workers }))
|
Ok(Json(RuntimeHttpWorkersResponse { workers }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn authorize_working_directory_repository_access(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
Extension(auth): Extension<RuntimeAuthContext>,
|
||||||
|
body: Result<Json<WorkingDirectoryRepositoryAccessRequest>, JsonRejection>,
|
||||||
|
) -> RestResult<RuntimeHttpRepositoryAccessResponse> {
|
||||||
|
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||||
|
if request.materialization.workspace_id != auth.workspace_id {
|
||||||
|
return Err(RuntimeHttpRestError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"working_directory_materialization_workspace_mismatch",
|
||||||
|
"Repository access authority does not match the authenticated Workspace",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.runtime
|
||||||
|
.authorize_working_directory_repository_access(request)
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
|
Ok(Json(RuntimeHttpRepositoryAccessResponse {
|
||||||
|
authorized: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_working_directories(
|
async fn list_working_directories(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
|
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
|
||||||
@@ -1569,6 +1601,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
if path == "/v1/workers" && *method == Method::POST {
|
if path == "/v1/workers" && *method == Method::POST {
|
||||||
return Some("workers:create");
|
return Some("workers:create");
|
||||||
}
|
}
|
||||||
|
if path == "/v1/working-directories/repository-access" && *method == Method::POST {
|
||||||
|
return Some("workdirs:operate");
|
||||||
|
}
|
||||||
if path.starts_with("/v1/workdir-sessions")
|
if path.starts_with("/v1/workdir-sessions")
|
||||||
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
|
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
|
||||||
{
|
{
|
||||||
@@ -2230,6 +2265,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workdir_routes_require_dedicated_operation_permission() {
|
fn workdir_routes_require_dedicated_operation_permission() {
|
||||||
|
assert_eq!(
|
||||||
|
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
|
||||||
|
Some("workdirs:operate")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
||||||
Some("workdirs:operate")
|
Some("workdirs:operate")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
|
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
|
||||||
WorkerStatus, WorkerSummary, WorkingDirectoryRequest,
|
WorkerStatus, WorkerSummary, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
|
||||||
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
|
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{
|
use crate::config_bundle::{
|
||||||
@@ -366,6 +366,25 @@ impl Runtime {
|
|||||||
.map_err(RuntimeError::from)
|
.map_err(RuntimeError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
request: WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
let backend = {
|
||||||
|
let state = self.lock()?;
|
||||||
|
state.ensure_running()?;
|
||||||
|
state.execution_backend.clone().ok_or_else(|| {
|
||||||
|
RuntimeError::ExecutionBackendUnavailable {
|
||||||
|
message: "working directory Repository access requires an execution backend"
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
backend
|
||||||
|
.authorize_working_directory_repository_access(&request)
|
||||||
|
.map_err(RuntimeError::from)
|
||||||
|
}
|
||||||
|
|
||||||
/// List Runtime-owned working directories through the attached execution backend.
|
/// List Runtime-owned working directories through the attached execution backend.
|
||||||
pub fn list_working_directories(
|
pub fn list_working_directories(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use crate::auth::{
|
|||||||
};
|
};
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
||||||
WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
};
|
};
|
||||||
use crate::execution::{
|
use crate::execution::{
|
||||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
|
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
|
||||||
@@ -1590,6 +1590,19 @@ where
|
|||||||
Ok(materializer.create(request)?.status())
|
Ok(materializer.create(request)?.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
let materializer = self.working_directory_materializer.as_ref().ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_materializer_unavailable",
|
||||||
|
"working directory Repository access requested, but no materializer is configured for this runtime backend",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
materializer.authorize_repository_access(request)
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
self.working_directory_materializer
|
self.working_directory_materializer
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
MaterializerKind, RepositorySshMaterializationAccess, WorkingDirectoryCleanupTarget,
|
MaterializerKind, RepositorySshMaterializationAccess, WorkingDirectoryCleanupTarget,
|
||||||
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkingDirectoryStatusKind,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
WorkingDirectorySummary,
|
WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
||||||
};
|
};
|
||||||
use crate::identity::WorkerRef;
|
use crate::identity::WorkerRef;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -48,6 +48,8 @@ pub struct WorkingDirectoryEvidence {
|
|||||||
pub credential_revision: Option<u64>,
|
pub credential_revision: Option<u64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub host_trust_revision: Option<u64>,
|
pub host_trust_revision: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub transport_warning: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -174,6 +176,11 @@ pub trait WorkingDirectoryMaterializer: Send + Sync + 'static {
|
|||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic>;
|
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic>;
|
||||||
|
|
||||||
|
fn authorize_repository_access(
|
||||||
|
&self,
|
||||||
|
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), WorkingDirectoryDiagnostic>;
|
||||||
|
|
||||||
fn bind_working_directory(
|
fn bind_working_directory(
|
||||||
&self,
|
&self,
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
@@ -379,8 +386,7 @@ impl RuntimeGitCacheMaterializer {
|
|||||||
"Runtime Repository access state is unavailable",
|
"Runtime Repository access state is unavailable",
|
||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
.get(working_directory_id)
|
.remove(working_directory_id);
|
||||||
.cloned();
|
|
||||||
let Some(access) = access else {
|
let Some(access) = access else {
|
||||||
if binding
|
if binding
|
||||||
.working_directory
|
.working_directory
|
||||||
@@ -395,11 +401,26 @@ impl RuntimeGitCacheMaterializer {
|
|||||||
}
|
}
|
||||||
return Ok(binding);
|
return Ok(binding);
|
||||||
};
|
};
|
||||||
|
validate_ssh_materialization_access(&access)?;
|
||||||
let agent = Arc::new(RepositorySshAgent::start(
|
let agent = Arc::new(RepositorySshAgent::start(
|
||||||
&self.runtime_root,
|
&self.runtime_root,
|
||||||
working_directory_id,
|
working_directory_id,
|
||||||
&access,
|
&access,
|
||||||
)?);
|
)?);
|
||||||
|
let weak_agent = Arc::downgrade(&agent);
|
||||||
|
let expires_at = access.expires_at_epoch_seconds;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
if expires_at > now {
|
||||||
|
std::thread::sleep(Duration::from_secs(expires_at - now));
|
||||||
|
}
|
||||||
|
if let Some(agent) = weak_agent.upgrade() {
|
||||||
|
agent.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
binding.command_environment.insert(
|
binding.command_environment.insert(
|
||||||
"SSH_AUTH_SOCK".to_string(),
|
"SSH_AUTH_SOCK".to_string(),
|
||||||
agent.socket.to_string_lossy().to_string(),
|
agent.socket.to_string_lossy().to_string(),
|
||||||
@@ -436,7 +457,9 @@ impl RuntimeGitCacheMaterializer {
|
|||||||
}
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
request.repository.source.kind,
|
request.repository.source.kind,
|
||||||
workspace_api::RepositorySourceKind::Https | workspace_api::RepositorySourceKind::Ssh
|
workspace_api::RepositorySourceKind::Https
|
||||||
|
| workspace_api::RepositorySourceKind::Http
|
||||||
|
| workspace_api::RepositorySourceKind::Ssh
|
||||||
) {
|
) {
|
||||||
validate_remote_source_uri(request)?;
|
validate_remote_source_uri(request)?;
|
||||||
let materialization = request.materialization.as_ref().ok_or_else(|| {
|
let materialization = request.materialization.as_ref().ok_or_else(|| {
|
||||||
@@ -460,7 +483,8 @@ impl RuntimeGitCacheMaterializer {
|
|||||||
match request.repository.source.kind {
|
match request.repository.source.kind {
|
||||||
workspace_api::RepositorySourceKind::LocalPath
|
workspace_api::RepositorySourceKind::LocalPath
|
||||||
| workspace_api::RepositorySourceKind::File
|
| workspace_api::RepositorySourceKind::File
|
||||||
| workspace_api::RepositorySourceKind::Https => {}
|
| workspace_api::RepositorySourceKind::Https
|
||||||
|
| workspace_api::RepositorySourceKind::Http => {}
|
||||||
workspace_api::RepositorySourceKind::Ssh => {
|
workspace_api::RepositorySourceKind::Ssh => {
|
||||||
let ssh = request
|
let ssh = request
|
||||||
.materialization
|
.materialization
|
||||||
@@ -474,12 +498,6 @@ impl RuntimeGitCacheMaterializer {
|
|||||||
})?;
|
})?;
|
||||||
validate_ssh_materialization_access(ssh)?;
|
validate_ssh_materialization_access(ssh)?;
|
||||||
}
|
}
|
||||||
workspace_api::RepositorySourceKind::Http => {
|
|
||||||
return Err(WorkingDirectoryDiagnostic::new(
|
|
||||||
"working_directory_insecure_repository_transport_rejected",
|
|
||||||
"plain HTTP Repository materialization is rejected",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
workspace_api::RepositorySourceKind::Invalid => {
|
workspace_api::RepositorySourceKind::Invalid => {
|
||||||
return Err(WorkingDirectoryDiagnostic::new(
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
"working_directory_repository_source_invalid",
|
"working_directory_repository_source_invalid",
|
||||||
@@ -539,12 +557,7 @@ impl RuntimeGitCacheMaterializer {
|
|||||||
"Runtime Repository cache identity does not match the requested source",
|
"Runtime Repository cache identity does not match the requested source",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut command = repository_git_command(request, access.as_ref());
|
fetch_repository_cache(request, access.as_ref(), &cache_path)?;
|
||||||
command
|
|
||||||
.arg("--git-dir")
|
|
||||||
.arg(&cache_path)
|
|
||||||
.args(["remote", "update", "--prune"]);
|
|
||||||
run_repository_git(command, "working_directory_repository_fetch_failed")?;
|
|
||||||
} else {
|
} else {
|
||||||
let staging = cache_path.with_extension(format!(
|
let staging = cache_path.with_extension(format!(
|
||||||
"staging-{}",
|
"staging-{}",
|
||||||
@@ -553,15 +566,42 @@ impl RuntimeGitCacheMaterializer {
|
|||||||
if staging.exists() {
|
if staging.exists() {
|
||||||
let _ = fs::remove_dir_all(&staging);
|
let _ = fs::remove_dir_all(&staging);
|
||||||
}
|
}
|
||||||
let mut command = repository_git_command(request, access.as_ref());
|
fs::create_dir_all(&staging).map_err(|_| {
|
||||||
command.args(["clone", "--mirror"]);
|
WorkingDirectoryDiagnostic::new(
|
||||||
if request.repository.source.kind == workspace_api::RepositorySourceKind::LocalPath {
|
"working_directory_repository_cache_create_failed",
|
||||||
command.arg("--no-local");
|
"Runtime Repository cache could not be created; backend-private path details were omitted",
|
||||||
}
|
)
|
||||||
command.arg(&request.repository.source.uri).arg(&staging);
|
})?;
|
||||||
if let Err(error) =
|
let mut init = isolated_git_command();
|
||||||
run_repository_git(command, "working_directory_repository_fetch_failed")
|
init.args(["init", "--bare"]).arg(&staging);
|
||||||
{
|
let mut add_origin = repository_git_command(request, access.as_ref());
|
||||||
|
add_origin
|
||||||
|
.arg("--git-dir")
|
||||||
|
.arg(&staging)
|
||||||
|
.args(["remote", "add", "origin"])
|
||||||
|
.arg(&request.repository.source.uri);
|
||||||
|
let mut configure_fetch = isolated_git_command();
|
||||||
|
configure_fetch.arg("--git-dir").arg(&staging).args([
|
||||||
|
"config",
|
||||||
|
"remote.origin.fetch",
|
||||||
|
"+refs/heads/*:refs/remotes/origin/*",
|
||||||
|
]);
|
||||||
|
let initialized =
|
||||||
|
run_repository_git(init, "working_directory_repository_cache_create_failed")
|
||||||
|
.and_then(|_| {
|
||||||
|
run_repository_git(
|
||||||
|
add_origin,
|
||||||
|
"working_directory_repository_cache_create_failed",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.and_then(|_| {
|
||||||
|
run_repository_git(
|
||||||
|
configure_fetch,
|
||||||
|
"working_directory_repository_cache_create_failed",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.and_then(|_| fetch_repository_cache(request, access.as_ref(), &staging));
|
||||||
|
if let Err(error) = initialized {
|
||||||
let _ = fs::remove_dir_all(&staging);
|
let _ = fs::remove_dir_all(&staging);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
@@ -590,9 +630,7 @@ impl RuntimeGitCacheMaterializer {
|
|||||||
validate_working_directory_id(&working_directory_id)?;
|
validate_working_directory_id(&working_directory_id)?;
|
||||||
let repository_cache = self.ensure_repository_cache(request)?;
|
let repository_cache = self.ensure_repository_cache(request)?;
|
||||||
let selector = request.repository.selector.as_deref().unwrap_or("HEAD");
|
let selector = request.repository.selector.as_deref().unwrap_or("HEAD");
|
||||||
let commit_spec = format!("{selector}^{{commit}}");
|
let resolved_commit = resolve_cached_commit(&repository_cache, selector)?;
|
||||||
let resolved_commit =
|
|
||||||
git_dir_stdout(&repository_cache, ["rev-parse", commit_spec.as_str()])?;
|
|
||||||
let tree_spec = format!("{resolved_commit}^{{tree}}");
|
let tree_spec = format!("{resolved_commit}^{{tree}}");
|
||||||
let resolved_tree = git_dir_stdout(&repository_cache, ["rev-parse", tree_spec.as_str()])
|
let resolved_tree = git_dir_stdout(&repository_cache, ["rev-parse", tree_spec.as_str()])
|
||||||
.ok()
|
.ok()
|
||||||
@@ -682,6 +720,8 @@ impl RuntimeGitCacheMaterializer {
|
|||||||
host_trust_revision: context
|
host_trust_revision: context
|
||||||
.and_then(|value| value.ssh.as_ref())
|
.and_then(|value| value.ssh.as_ref())
|
||||||
.map(|value| value.host_trust_revision),
|
.map(|value| value.host_trust_revision),
|
||||||
|
transport_warning: repository_transport_warning(request.repository.source.kind)
|
||||||
|
.map(str::to_string),
|
||||||
},
|
},
|
||||||
cleanup_target: WorkingDirectoryCleanupTarget {
|
cleanup_target: WorkingDirectoryCleanupTarget {
|
||||||
kind: "runtime_git_cache_worktree".to_string(),
|
kind: "runtime_git_cache_worktree".to_string(),
|
||||||
@@ -704,24 +744,6 @@ impl RuntimeGitCacheMaterializer {
|
|||||||
let _ = fs::remove_dir_all(&working_directory_root);
|
let _ = fs::remove_dir_all(&working_directory_root);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
if let Some(ssh) = request
|
|
||||||
.materialization
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|materialization| materialization.ssh.clone())
|
|
||||||
{
|
|
||||||
let mut repository_access = match self.repository_access.lock() {
|
|
||||||
Ok(repository_access) => repository_access,
|
|
||||||
Err(_) => {
|
|
||||||
remove_cached_worktree(&repository_cache, &worktree_root);
|
|
||||||
let _ = fs::remove_dir_all(&working_directory_root);
|
|
||||||
return Err(WorkingDirectoryDiagnostic::new(
|
|
||||||
"working_directory_repository_access_unavailable",
|
|
||||||
"Runtime Repository access state is unavailable",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
repository_access.insert(binding.working_directory.id.clone(), ssh);
|
|
||||||
}
|
|
||||||
Ok(binding)
|
Ok(binding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -747,6 +769,67 @@ impl WorkingDirectoryMaterializer for RuntimeGitCacheMaterializer {
|
|||||||
self.materialize_with_working_directory_id(working_directory_id, request)
|
self.materialize_with_working_directory_id(working_directory_id, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authorize_repository_access(
|
||||||
|
&self,
|
||||||
|
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
validate_working_directory_id(&request.working_directory_id)?;
|
||||||
|
let ssh = request.materialization.ssh.as_ref().ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"working_directory_remote_repository_access_required",
|
||||||
|
"SSH Repository access authority is required",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
validate_ssh_materialization_access(ssh)?;
|
||||||
|
let mut binding = self.read_binding(&request.working_directory_id)?;
|
||||||
|
if binding
|
||||||
|
.working_directory
|
||||||
|
.evidence
|
||||||
|
.credential_revision
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"working_directory_repository_access_not_applicable",
|
||||||
|
"Workdir is not backed by an SSH Repository",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
binding.working_directory.evidence.operation_id =
|
||||||
|
Some(request.materialization.operation_id.clone());
|
||||||
|
binding.working_directory.evidence.credential_revision = Some(ssh.credential_revision);
|
||||||
|
binding.working_directory.evidence.host_trust_revision = Some(ssh.host_trust_revision);
|
||||||
|
self.write_record(&binding)?;
|
||||||
|
let working_directory_id = request.working_directory_id.clone();
|
||||||
|
let credential_revision = ssh.credential_revision;
|
||||||
|
let expires_at = ssh.expires_at_epoch_seconds;
|
||||||
|
self.repository_access
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"working_directory_repository_access_unavailable",
|
||||||
|
"Runtime Repository access state is unavailable",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.insert(working_directory_id.clone(), ssh.clone());
|
||||||
|
let repository_access = self.repository_access.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
if expires_at > now {
|
||||||
|
std::thread::sleep(Duration::from_secs(expires_at - now));
|
||||||
|
}
|
||||||
|
if let Ok(mut access) = repository_access.lock()
|
||||||
|
&& access
|
||||||
|
.get(&working_directory_id)
|
||||||
|
.is_some_and(|access| access.credential_revision == credential_revision)
|
||||||
|
{
|
||||||
|
access.remove(&working_directory_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn bind_working_directory(
|
fn bind_working_directory(
|
||||||
&self,
|
&self,
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
@@ -1021,10 +1104,8 @@ impl RepositorySshAgent {
|
|||||||
}
|
}
|
||||||
Ok(agent)
|
Ok(agent)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for RepositorySshAgent {
|
fn stop(&self) {
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Ok(mut child) = self.child.lock()
|
if let Ok(mut child) = self.child.lock()
|
||||||
&& let Some(mut child) = child.take()
|
&& let Some(mut child) = child.take()
|
||||||
{
|
{
|
||||||
@@ -1035,9 +1116,16 @@ impl Drop for RepositorySshAgent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for RepositorySshAgent {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct RepositoryCommandAccess {
|
struct RepositoryCommandAccess {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
ssh_command: PathBuf,
|
ssh_command: PathBuf,
|
||||||
|
agent: RepositorySshAgent,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RepositoryCommandAccess {
|
impl RepositoryCommandAccess {
|
||||||
@@ -1045,6 +1133,9 @@ impl RepositoryCommandAccess {
|
|||||||
runtime_root: &Path,
|
runtime_root: &Path,
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
) -> Result<Option<Self>, WorkingDirectoryDiagnostic> {
|
) -> Result<Option<Self>, WorkingDirectoryDiagnostic> {
|
||||||
|
if request.repository.source.kind != workspace_api::RepositorySourceKind::Ssh {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let Some(ssh) = request
|
let Some(ssh) = request
|
||||||
.materialization
|
.materialization
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1069,19 +1160,21 @@ impl RepositoryCommandAccess {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
set_directory_owner_only(&root)?;
|
set_directory_owner_only(&root)?;
|
||||||
let private_key = root.join("identity");
|
|
||||||
let known_hosts = root.join("known_hosts");
|
let known_hosts = root.join("known_hosts");
|
||||||
let ssh_command = root.join("ssh-command");
|
let ssh_command = root.join("ssh-command");
|
||||||
write_owner_only(&private_key, ssh.private_key.expose().as_bytes())?;
|
|
||||||
write_owner_only(&known_hosts, ssh.known_hosts_entry.expose().as_bytes())?;
|
write_owner_only(&known_hosts, ssh.known_hosts_entry.expose().as_bytes())?;
|
||||||
let script = format!(
|
let script = format!(
|
||||||
"#!/bin/sh\nexec ssh -F /dev/null -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile={} -i {} \"$@\"\n",
|
"#!/bin/sh\nexec ssh -F /dev/null -o BatchMode=yes -o IdentitiesOnly=no -o IdentityFile=/dev/null -o StrictHostKeyChecking=yes -o UserKnownHostsFile={} \"$@\"\n",
|
||||||
shell_quote_path(&known_hosts)?,
|
shell_quote_path(&known_hosts)?,
|
||||||
shell_quote_path(&private_key)?,
|
|
||||||
);
|
);
|
||||||
write_owner_only(&ssh_command, script.as_bytes())?;
|
write_owner_only(&ssh_command, script.as_bytes())?;
|
||||||
set_file_owner_executable(&ssh_command)?;
|
set_file_owner_executable(&ssh_command)?;
|
||||||
Ok(Some(Self { root, ssh_command }))
|
let agent = RepositorySshAgent::start(runtime_root, operation_id, ssh)?;
|
||||||
|
Ok(Some(Self {
|
||||||
|
root,
|
||||||
|
ssh_command,
|
||||||
|
agent,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1094,6 +1187,16 @@ impl Drop for RepositoryCommandAccess {
|
|||||||
fn validate_ssh_materialization_access(
|
fn validate_ssh_materialization_access(
|
||||||
access: &RepositorySshMaterializationAccess,
|
access: &RepositorySshMaterializationAccess,
|
||||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
if access.expires_at_epoch_seconds <= now {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"working_directory_repository_access_expired",
|
||||||
|
"operation-scoped SSH credential and host-trust authority has expired",
|
||||||
|
));
|
||||||
|
}
|
||||||
if access.credential_id.trim().is_empty()
|
if access.credential_id.trim().is_empty()
|
||||||
|| access.credential_revision == 0
|
|| access.credential_revision == 0
|
||||||
|| access.host_trust_id.trim().is_empty()
|
|| access.host_trust_id.trim().is_empty()
|
||||||
@@ -1109,6 +1212,10 @@ fn validate_ssh_materialization_access(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repository_transport_warning(kind: workspace_api::RepositorySourceKind) -> Option<&'static str> {
|
||||||
|
(kind == workspace_api::RepositorySourceKind::Http).then_some("plain_http_transport")
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_remote_source_uri(
|
fn validate_remote_source_uri(
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
@@ -1120,6 +1227,7 @@ fn validate_remote_source_uri(
|
|||||||
})?;
|
})?;
|
||||||
let expected_scheme = match request.repository.source.kind {
|
let expected_scheme = match request.repository.source.kind {
|
||||||
workspace_api::RepositorySourceKind::Https => "https",
|
workspace_api::RepositorySourceKind::Https => "https",
|
||||||
|
workspace_api::RepositorySourceKind::Http => "http",
|
||||||
workspace_api::RepositorySourceKind::Ssh => "ssh",
|
workspace_api::RepositorySourceKind::Ssh => "ssh",
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
@@ -1128,7 +1236,7 @@ fn validate_remote_source_uri(
|
|||||||
|| url.password().is_some()
|
|| url.password().is_some()
|
||||||
|| !url.query().is_none()
|
|| !url.query().is_none()
|
||||||
|| !url.fragment().is_none()
|
|| !url.fragment().is_none()
|
||||||
|| (expected_scheme == "https" && !url.username().is_empty())
|
|| (matches!(expected_scheme, "https" | "http") && !url.username().is_empty())
|
||||||
{
|
{
|
||||||
return Err(WorkingDirectoryDiagnostic::new(
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
"working_directory_repository_source_invalid",
|
"working_directory_repository_source_invalid",
|
||||||
@@ -1223,7 +1331,9 @@ fn repository_git_command(
|
|||||||
};
|
};
|
||||||
command.args(["-c", &format!("protocol.file.allow={file_policy}")]);
|
command.args(["-c", &format!("protocol.file.allow={file_policy}")]);
|
||||||
if let Some(access) = access {
|
if let Some(access) = access {
|
||||||
command.env("GIT_SSH_COMMAND", &access.ssh_command);
|
command
|
||||||
|
.env("GIT_SSH_COMMAND", &access.ssh_command)
|
||||||
|
.env("SSH_AUTH_SOCK", &access.agent.socket);
|
||||||
}
|
}
|
||||||
command
|
command
|
||||||
}
|
}
|
||||||
@@ -1273,6 +1383,57 @@ fn run_repository_git(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_cached_commit(
|
||||||
|
repository_cache: &Path,
|
||||||
|
selector: &str,
|
||||||
|
) -> Result<String, WorkingDirectoryDiagnostic> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
if selector == "HEAD" {
|
||||||
|
candidates.push("FETCH_HEAD".to_string());
|
||||||
|
} else if let Some(branch) = selector.strip_prefix("refs/heads/") {
|
||||||
|
candidates.push(format!("refs/remotes/origin/{branch}"));
|
||||||
|
} else if selector.starts_with("refs/") || selector.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
{
|
||||||
|
candidates.push(selector.to_string());
|
||||||
|
} else {
|
||||||
|
candidates.push(format!("refs/remotes/origin/{selector}"));
|
||||||
|
candidates.push(selector.to_string());
|
||||||
|
}
|
||||||
|
for candidate in candidates {
|
||||||
|
let spec = format!("{candidate}^{{commit}}");
|
||||||
|
if let Ok(commit) = git_dir_stdout(repository_cache, ["rev-parse", spec.as_str()])
|
||||||
|
&& !commit.is_empty()
|
||||||
|
{
|
||||||
|
return Ok(commit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"working_directory_repository_selector_unresolved",
|
||||||
|
"configured Repository selector could not be resolved to a commit",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_repository_cache(
|
||||||
|
request: &WorkingDirectoryRequest,
|
||||||
|
access: Option<&RepositoryCommandAccess>,
|
||||||
|
repository_cache: &Path,
|
||||||
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
let mut refs = repository_git_command(request, access);
|
||||||
|
refs.arg("--git-dir").arg(repository_cache).args([
|
||||||
|
"fetch",
|
||||||
|
"--prune",
|
||||||
|
"--tags",
|
||||||
|
"origin",
|
||||||
|
"+refs/heads/*:refs/remotes/origin/*",
|
||||||
|
]);
|
||||||
|
let mut head = repository_git_command(request, access);
|
||||||
|
head.arg("--git-dir")
|
||||||
|
.arg(repository_cache)
|
||||||
|
.args(["fetch", "--no-tags", "origin", "HEAD"]);
|
||||||
|
run_repository_git(refs, "working_directory_repository_fetch_failed")?;
|
||||||
|
run_repository_git(head, "working_directory_repository_fetch_failed")
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_repository_cache_limits(
|
fn validate_repository_cache_limits(
|
||||||
repository_cache: &Path,
|
repository_cache: &Path,
|
||||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
@@ -1693,6 +1854,21 @@ mod tests {
|
|||||||
local.working_directory.evidence.repository_cache_key,
|
local.working_directory.evidence.repository_cache_key,
|
||||||
second.working_directory.evidence.repository_cache_key
|
second.working_directory.evidence.repository_cache_key
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
git_dir_stdout(
|
||||||
|
local.source_repository_path(),
|
||||||
|
["config", "--get-all", "remote.origin.fetch"],
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"+refs/heads/*:refs/remotes/origin/*"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
git_dir_stdout(
|
||||||
|
local.source_repository_path(),
|
||||||
|
["config", "--get", "remote.origin.mirror"],
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs::read_dir(runtime_root.path().join(REPOSITORY_CACHE_DIR))
|
fs::read_dir(runtime_root.path().join(REPOSITORY_CACHE_DIR))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1754,6 +1930,7 @@ mod tests {
|
|||||||
host_trust_id: "trust-1".to_string(),
|
host_trust_id: "trust-1".to_string(),
|
||||||
host_trust_revision: 4,
|
host_trust_revision: 4,
|
||||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
|
expires_at_epoch_seconds: u64::MAX,
|
||||||
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY secret bytes"),
|
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY secret bytes"),
|
||||||
known_hosts_entry: crate::catalog::SensitiveString::new("host key secret bytes"),
|
known_hosts_entry: crate::catalog::SensitiveString::new("host key secret bytes"),
|
||||||
};
|
};
|
||||||
@@ -1790,6 +1967,7 @@ mod tests {
|
|||||||
host_trust_id: "trust-1".to_string(),
|
host_trust_id: "trust-1".to_string(),
|
||||||
host_trust_revision: 1,
|
host_trust_revision: 1,
|
||||||
access: workspace_api::RepositoryAccessMode::ReadWrite,
|
access: workspace_api::RepositoryAccessMode::ReadWrite,
|
||||||
|
expires_at_epoch_seconds: u64::MAX,
|
||||||
private_key: crate::catalog::SensitiveString::new(
|
private_key: crate::catalog::SensitiveString::new(
|
||||||
fs::read_to_string(&key_path).unwrap(),
|
fs::read_to_string(&key_path).unwrap(),
|
||||||
),
|
),
|
||||||
@@ -1798,10 +1976,35 @@ mod tests {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
let mut ssh_operation = request.clone();
|
||||||
|
ssh_operation.repository.source = workspace_api::RepositorySource {
|
||||||
|
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||||
|
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||||
|
};
|
||||||
|
let command_access = RepositoryCommandAccess::prepare(runtime_root.path(), &ssh_operation)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!command_access.root.join("identity").exists());
|
||||||
|
assert!(command_access.agent.socket.exists());
|
||||||
|
let operation_socket = command_access.agent.socket.clone();
|
||||||
|
drop(command_access);
|
||||||
|
assert!(!operation_socket.exists());
|
||||||
|
|
||||||
let created = materializer.create(&request).unwrap();
|
let created = materializer.create(&request).unwrap();
|
||||||
let id = created.working_directory.id;
|
let id = created.working_directory.id;
|
||||||
assert_eq!(materializer.list_working_directories().unwrap().len(), 1);
|
assert_eq!(materializer.list_working_directories().unwrap().len(), 1);
|
||||||
assert!(!runtime_root.path().join(".repository-agents").exists());
|
assert_eq!(
|
||||||
|
fs::read_dir(runtime_root.path().join(".repository-agents"))
|
||||||
|
.map(|entries| entries.count())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
materializer
|
||||||
|
.authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest {
|
||||||
|
working_directory_id: id.clone(),
|
||||||
|
materialization: request.materialization.clone().unwrap(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
let binding = materializer.bind_working_directory(&id, None).unwrap();
|
let binding = materializer.bind_working_directory(&id, None).unwrap();
|
||||||
let socket = PathBuf::from(binding.command_environment()["SSH_AUTH_SOCK"].clone());
|
let socket = PathBuf::from(binding.command_environment()["SSH_AUTH_SOCK"].clone());
|
||||||
assert!(socket.exists());
|
assert!(socket.exists());
|
||||||
@@ -1811,6 +2014,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
drop(binding);
|
drop(binding);
|
||||||
assert!(!socket.exists());
|
assert!(!socket.exists());
|
||||||
|
assert_eq!(
|
||||||
|
materializer
|
||||||
|
.bind_working_directory(&id, None)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"working_directory_remote_repository_access_required"
|
||||||
|
);
|
||||||
|
let mut rotated = request.materialization.clone().unwrap();
|
||||||
|
rotated.operation_id = "operation-agent-rotated".to_string();
|
||||||
|
rotated.ssh.as_mut().unwrap().credential_revision = 2;
|
||||||
|
materializer
|
||||||
|
.authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest {
|
||||||
|
working_directory_id: id.clone(),
|
||||||
|
materialization: rotated,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let rebound = materializer.bind_working_directory(&id, None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rebound.working_directory.evidence.credential_revision,
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
drop(rebound);
|
||||||
|
let mut expired = request.materialization.clone().unwrap();
|
||||||
|
expired.ssh.as_mut().unwrap().expires_at_epoch_seconds = 1;
|
||||||
|
assert_eq!(
|
||||||
|
materializer
|
||||||
|
.authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest {
|
||||||
|
working_directory_id: id.clone(),
|
||||||
|
materialization: expired,
|
||||||
|
})
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"working_directory_repository_access_expired"
|
||||||
|
);
|
||||||
let restored = RuntimeGitCacheMaterializer::new(runtime_root.path());
|
let restored = RuntimeGitCacheMaterializer::new(runtime_root.path());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
restored.bind_working_directory(&id, None).unwrap_err().code,
|
restored.bind_working_directory(&id, None).unwrap_err().code,
|
||||||
@@ -1837,6 +2074,7 @@ mod tests {
|
|||||||
host_trust_id: "trust-1".to_string(),
|
host_trust_id: "trust-1".to_string(),
|
||||||
host_trust_revision: 1,
|
host_trust_revision: 1,
|
||||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
|
expires_at_epoch_seconds: u64::MAX,
|
||||||
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
|
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
|
||||||
known_hosts_entry: crate::catalog::SensitiveString::new(
|
known_hosts_entry: crate::catalog::SensitiveString::new(
|
||||||
"example.test ssh-ed25519 placeholder",
|
"example.test ssh-ed25519 placeholder",
|
||||||
@@ -1902,6 +2140,18 @@ mod tests {
|
|||||||
"working_directory_repository_source_invalid"
|
"working_directory_repository_source_invalid"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let mut http = request(repo.path());
|
||||||
|
http.repository.source = workspace_api::RepositorySource {
|
||||||
|
kind: workspace_api::RepositorySourceKind::Http,
|
||||||
|
uri: "http://example.test/repo.git".to_string(),
|
||||||
|
};
|
||||||
|
http.materialization = Some(context(None));
|
||||||
|
RuntimeGitCacheMaterializer::validate_request(&http).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
repository_transport_warning(http.repository.source.kind),
|
||||||
|
Some("plain_http_transport")
|
||||||
|
);
|
||||||
|
|
||||||
let mut ssh = request(repo.path());
|
let mut ssh = request(repo.path());
|
||||||
ssh.repository.source = workspace_api::RepositorySource {
|
ssh.repository.source = workspace_api::RepositorySource {
|
||||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||||
@@ -1914,6 +2164,7 @@ mod tests {
|
|||||||
host_trust_id: "trust-1".to_string(),
|
host_trust_id: "trust-1".to_string(),
|
||||||
host_trust_revision: 1,
|
host_trust_revision: 1,
|
||||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
|
expires_at_epoch_seconds: u64::MAX,
|
||||||
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
|
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
|
||||||
known_hosts_entry: crate::catalog::SensitiveString::new(
|
known_hosts_entry: crate::catalog::SensitiveString::new(
|
||||||
"other.test ssh-ed25519 placeholder",
|
"other.test ssh-ed25519 placeholder",
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
|||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
||||||
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
|
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
|
||||||
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest,
|
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim,
|
||||||
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
|
WorkingDirectorySummary, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -39,11 +40,11 @@ use worker_runtime::execution::WorkerExecutionRunState;
|
|||||||
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
||||||
use worker_runtime::http_server::{
|
use worker_runtime::http_server::{
|
||||||
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
|
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
|
||||||
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerCompletionsRequest,
|
RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse,
|
||||||
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse,
|
RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse,
|
||||||
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
|
RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse,
|
||||||
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
|
RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
|
||||||
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
||||||
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
||||||
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
|
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
|
||||||
};
|
};
|
||||||
@@ -854,6 +855,16 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
_request: WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> std::result::Result<(), Error> {
|
||||||
|
Err(Error::InvalidInput(
|
||||||
|
"Runtime does not support working directory Repository access authorization"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||||
RuntimeList::new(Vec::new(), Vec::new())
|
RuntimeList::new(Vec::new(), Vec::new())
|
||||||
}
|
}
|
||||||
@@ -1427,6 +1438,23 @@ impl RuntimeRegistry {
|
|||||||
Ok(runtime.create_working_directory(request))
|
Ok(runtime.create_working_directory(request))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
request: WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||||
|
validate_backend_identifier("working_directory_id", &request.working_directory_id)?;
|
||||||
|
let runtime = self.runtime(runtime_id)?;
|
||||||
|
runtime
|
||||||
|
.authorize_working_directory_repository_access(request)
|
||||||
|
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code: "working_directory_repository_access_failed".to_string(),
|
||||||
|
message: error.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_working_directories(
|
pub fn list_working_directories(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
@@ -3205,6 +3233,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
request: WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> std::result::Result<(), Error> {
|
||||||
|
self.post_json::<_, RuntimeHttpRepositoryAccessResponse>(
|
||||||
|
"/v1/working-directories/repository-access",
|
||||||
|
&request,
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||||
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
|
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
|
||||||
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
|
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
|
||||||
|
|||||||
@@ -1437,6 +1437,18 @@ impl WorkspaceApi {
|
|||||||
self.validate_worker_spawn_repository_scope(&request)?;
|
self.validate_worker_spawn_repository_scope(&request)?;
|
||||||
let workspace_api = self.workspace_api_ref(runtime_id);
|
let workspace_api = self.workspace_api_ref(runtime_id);
|
||||||
request.resolved_workspace_api = Some(workspace_api.clone());
|
request.resolved_workspace_api = Some(workspace_api.clone());
|
||||||
|
if let Some(working_directory) = request.resolved_working_directory.as_ref()
|
||||||
|
&& let Some(access) = repository_access_request_for_workdir(
|
||||||
|
self,
|
||||||
|
runtime_id,
|
||||||
|
&working_directory.working_directory_id,
|
||||||
|
&format!("worker-spawn:{}", WorkerId::now_v7()),
|
||||||
|
)?
|
||||||
|
{
|
||||||
|
self.runtime
|
||||||
|
.authorize_working_directory_repository_access(runtime_id, access)
|
||||||
|
.map_err(RuntimeRegistryError::into_error)?;
|
||||||
|
}
|
||||||
let attachment_reservation =
|
let attachment_reservation =
|
||||||
request
|
request
|
||||||
.resolved_working_directory
|
.resolved_working_directory
|
||||||
@@ -1652,6 +1664,22 @@ impl WorkspaceApi {
|
|||||||
&self,
|
&self,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
) -> ApiResult<WorkerRestoreResult> {
|
) -> ApiResult<WorkerRestoreResult> {
|
||||||
|
if let Some(link) = self
|
||||||
|
.store
|
||||||
|
.list_worker_workdir_links(&self.config.workspace_id, worker)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|link| link.unlinked_at.is_none())
|
||||||
|
&& let Some(access) = repository_access_request_for_workdir(
|
||||||
|
self,
|
||||||
|
&worker.runtime_id,
|
||||||
|
&link.workdir_id,
|
||||||
|
&format!("worker-restore:{}", WorkerId::now_v7()),
|
||||||
|
)?
|
||||||
|
{
|
||||||
|
self.runtime
|
||||||
|
.authorize_working_directory_repository_access(&worker.runtime_id, access)
|
||||||
|
.map_err(RuntimeRegistryError::into_error)?;
|
||||||
|
}
|
||||||
let binding = self
|
let binding = self
|
||||||
.runtime
|
.runtime
|
||||||
.replace_worker_workspace_api(worker, self.workspace_api_ref(&worker.runtime_id))
|
.replace_worker_workspace_api(worker, self.workspace_api_ref(&worker.runtime_id))
|
||||||
@@ -13967,6 +13995,11 @@ fn authorize_repository_materialization(
|
|||||||
host_trust_id: lease.host_trust_id,
|
host_trust_id: lease.host_trust_id,
|
||||||
host_trust_revision: lease.host_trust_revision,
|
host_trust_revision: lease.host_trust_revision,
|
||||||
access: binding.access,
|
access: binding.access,
|
||||||
|
expires_at_epoch_seconds: std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
.saturating_add(300),
|
||||||
private_key: SensitiveString::new(lease.private_key.as_str()),
|
private_key: SensitiveString::new(lease.private_key.as_str()),
|
||||||
known_hosts_entry: SensitiveString::new(lease.known_hosts_entry),
|
known_hosts_entry: SensitiveString::new(lease.known_hosts_entry),
|
||||||
})
|
})
|
||||||
@@ -13985,6 +14018,47 @@ fn authorize_repository_materialization(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repository_access_request_for_workdir(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
runtime_id: &str,
|
||||||
|
working_directory_id: &str,
|
||||||
|
operation_id: &str,
|
||||||
|
) -> ApiResult<Option<worker_runtime::catalog::WorkingDirectoryRepositoryAccessRequest>> {
|
||||||
|
let record = api
|
||||||
|
.config_store
|
||||||
|
.get_workdir_registry(&api.config.workspace_id, working_directory_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_not_found",
|
||||||
|
"Working directory is not registered in this Workspace",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if record.runtime_id != runtime_id {
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"working_directory_runtime_mismatch",
|
||||||
|
"Working directory is owned by a different Runtime",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let repository = api.require_configured_workspace_repository(&record.repository_id)?;
|
||||||
|
if repository.source.kind != workspace_api::RepositorySourceKind::Ssh {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let projection = active_repository_access_projection(api, &api.config.workspace_id)?;
|
||||||
|
let mut request = working_directory_request_from_repository(&repository, None);
|
||||||
|
authorize_repository_materialization(api, runtime_id, operation_id, &projection, &mut request)?;
|
||||||
|
Ok(Some(
|
||||||
|
worker_runtime::catalog::WorkingDirectoryRepositoryAccessRequest {
|
||||||
|
working_directory_id: working_directory_id.to_string(),
|
||||||
|
materialization: request.materialization.ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_remote_repository_access_required",
|
||||||
|
"SSH Repository access authority is unavailable",
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn working_directory_request_for_browser(
|
fn working_directory_request_for_browser(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
request: BrowserWorkingDirectoryCreateRequest,
|
request: BrowserWorkingDirectoryCreateRequest,
|
||||||
|
|||||||
Reference in New Issue
Block a user