fix: enforce repository access and cache boundaries

This commit is contained in:
2026-08-26 14:39:37 +09:00
parent 3a3c89e0b4
commit ffb2a34ae5
8 changed files with 533 additions and 70 deletions
+7
View File
@@ -129,6 +129,7 @@ pub struct RepositorySshMaterializationAccess {
pub host_trust_id: String,
pub host_trust_revision: u64,
pub access: workspace_api::RepositoryAccessMode,
pub expires_at_epoch_seconds: u64,
pub private_key: SensitiveString,
pub known_hosts_entry: SensitiveString,
}
@@ -146,6 +147,12 @@ pub struct RepositoryMaterializationContext {
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)]
pub struct WorkingDirectoryRequest {
pub repository: WorkingDirectoryRepository,
+21 -1
View File
@@ -1,4 +1,6 @@
use crate::catalog::{WorkingDirectoryRequest, WorkingDirectoryStatus};
use crate::catalog::{
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
};
use crate::config_bundle::ConfigBundle;
use crate::error::RuntimeError;
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> {
Vec::new()
}
@@ -454,6 +466,14 @@ impl WorkerExecutionBackendRef {
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> {
self.backend.list_working_directories()
}
+40 -1
View File
@@ -12,7 +12,8 @@ use crate::auth::{
};
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkspaceApiRef,
};
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
use crate::error::RuntimeError;
@@ -203,6 +204,10 @@ fn runtime_http_router_with_optional_auth(
"/v1/working-directories",
get(list_working_directories).post(create_working_directory),
)
.route(
"/v1/working-directories/repository-access",
post(authorize_working_directory_repository_access),
)
.route(
"/v1/working-directories/{working_directory_id}/sessions",
post(open_workdir_session),
@@ -335,6 +340,11 @@ pub struct RuntimeHttpWorkingDirectoriesResponse {
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.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkingDirectoryResponse {
@@ -513,6 +523,28 @@ async fn list_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(
State(state): State<RuntimeHttpState>,
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
@@ -1569,6 +1601,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
if path == "/v1/workers" && *method == Method::POST {
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")
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
{
@@ -2230,6 +2265,10 @@ mod tests {
#[test]
fn workdir_routes_require_dedicated_operation_permission() {
assert_eq!(
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
Some("workdirs:operate")
);
assert_eq!(
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
Some("workdirs:operate")
+20 -1
View File
@@ -1,6 +1,6 @@
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
WorkerStatus, WorkerSummary, WorkingDirectoryRequest,
WorkerStatus, WorkerSummary, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
};
use crate::config_bundle::{
@@ -366,6 +366,25 @@ impl Runtime {
.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.
pub fn list_working_directories(
&self,
+14 -1
View File
@@ -20,7 +20,7 @@ use crate::auth::{
};
use crate::catalog::{
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
};
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
@@ -1590,6 +1590,19 @@ where
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> {
self.working_directory_materializer
.as_ref()
+310 -59
View File
@@ -1,7 +1,7 @@
use crate::catalog::{
MaterializerKind, RepositorySshMaterializationAccess, WorkingDirectoryCleanupTarget,
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkingDirectoryStatusKind,
WorkingDirectorySummary,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkingDirectoryStatusKind, WorkingDirectorySummary,
};
use crate::identity::WorkerRef;
use serde::{Deserialize, Serialize};
@@ -48,6 +48,8 @@ pub struct WorkingDirectoryEvidence {
pub credential_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
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)]
@@ -174,6 +176,11 @@ pub trait WorkingDirectoryMaterializer: Send + Sync + 'static {
request: &WorkingDirectoryRequest,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic>;
fn authorize_repository_access(
&self,
request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic>;
fn bind_working_directory(
&self,
working_directory_id: &str,
@@ -379,8 +386,7 @@ impl RuntimeGitCacheMaterializer {
"Runtime Repository access state is unavailable",
)
})?
.get(working_directory_id)
.cloned();
.remove(working_directory_id);
let Some(access) = access else {
if binding
.working_directory
@@ -395,11 +401,26 @@ impl RuntimeGitCacheMaterializer {
}
return Ok(binding);
};
validate_ssh_materialization_access(&access)?;
let agent = Arc::new(RepositorySshAgent::start(
&self.runtime_root,
working_directory_id,
&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(
"SSH_AUTH_SOCK".to_string(),
agent.socket.to_string_lossy().to_string(),
@@ -436,7 +457,9 @@ impl RuntimeGitCacheMaterializer {
}
if matches!(
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)?;
let materialization = request.materialization.as_ref().ok_or_else(|| {
@@ -460,7 +483,8 @@ impl RuntimeGitCacheMaterializer {
match request.repository.source.kind {
workspace_api::RepositorySourceKind::LocalPath
| workspace_api::RepositorySourceKind::File
| workspace_api::RepositorySourceKind::Https => {}
| workspace_api::RepositorySourceKind::Https
| workspace_api::RepositorySourceKind::Http => {}
workspace_api::RepositorySourceKind::Ssh => {
let ssh = request
.materialization
@@ -474,12 +498,6 @@ impl RuntimeGitCacheMaterializer {
})?;
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 => {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_repository_source_invalid",
@@ -539,12 +557,7 @@ impl RuntimeGitCacheMaterializer {
"Runtime Repository cache identity does not match the requested source",
));
}
let mut command = repository_git_command(request, access.as_ref());
command
.arg("--git-dir")
.arg(&cache_path)
.args(["remote", "update", "--prune"]);
run_repository_git(command, "working_directory_repository_fetch_failed")?;
fetch_repository_cache(request, access.as_ref(), &cache_path)?;
} else {
let staging = cache_path.with_extension(format!(
"staging-{}",
@@ -553,15 +566,42 @@ impl RuntimeGitCacheMaterializer {
if staging.exists() {
let _ = fs::remove_dir_all(&staging);
}
let mut command = repository_git_command(request, access.as_ref());
command.args(["clone", "--mirror"]);
if request.repository.source.kind == workspace_api::RepositorySourceKind::LocalPath {
command.arg("--no-local");
}
command.arg(&request.repository.source.uri).arg(&staging);
if let Err(error) =
run_repository_git(command, "working_directory_repository_fetch_failed")
{
fs::create_dir_all(&staging).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_cache_create_failed",
"Runtime Repository cache could not be created; backend-private path details were omitted",
)
})?;
let mut init = isolated_git_command();
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);
return Err(error);
}
@@ -590,9 +630,7 @@ impl RuntimeGitCacheMaterializer {
validate_working_directory_id(&working_directory_id)?;
let repository_cache = self.ensure_repository_cache(request)?;
let selector = request.repository.selector.as_deref().unwrap_or("HEAD");
let commit_spec = format!("{selector}^{{commit}}");
let resolved_commit =
git_dir_stdout(&repository_cache, ["rev-parse", commit_spec.as_str()])?;
let resolved_commit = resolve_cached_commit(&repository_cache, selector)?;
let tree_spec = format!("{resolved_commit}^{{tree}}");
let resolved_tree = git_dir_stdout(&repository_cache, ["rev-parse", tree_spec.as_str()])
.ok()
@@ -682,6 +720,8 @@ impl RuntimeGitCacheMaterializer {
host_trust_revision: context
.and_then(|value| value.ssh.as_ref())
.map(|value| value.host_trust_revision),
transport_warning: repository_transport_warning(request.repository.source.kind)
.map(str::to_string),
},
cleanup_target: WorkingDirectoryCleanupTarget {
kind: "runtime_git_cache_worktree".to_string(),
@@ -704,24 +744,6 @@ impl RuntimeGitCacheMaterializer {
let _ = fs::remove_dir_all(&working_directory_root);
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)
}
}
@@ -747,6 +769,67 @@ impl WorkingDirectoryMaterializer for RuntimeGitCacheMaterializer {
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(
&self,
working_directory_id: &str,
@@ -1021,10 +1104,8 @@ impl RepositorySshAgent {
}
Ok(agent)
}
}
impl Drop for RepositorySshAgent {
fn drop(&mut self) {
fn stop(&self) {
if let Ok(mut child) = self.child.lock()
&& 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 {
root: PathBuf,
ssh_command: PathBuf,
agent: RepositorySshAgent,
}
impl RepositoryCommandAccess {
@@ -1045,6 +1133,9 @@ impl RepositoryCommandAccess {
runtime_root: &Path,
request: &WorkingDirectoryRequest,
) -> Result<Option<Self>, WorkingDirectoryDiagnostic> {
if request.repository.source.kind != workspace_api::RepositorySourceKind::Ssh {
return Ok(None);
}
let Some(ssh) = request
.materialization
.as_ref()
@@ -1069,19 +1160,21 @@ impl RepositoryCommandAccess {
)
})?;
set_directory_owner_only(&root)?;
let private_key = root.join("identity");
let known_hosts = root.join("known_hosts");
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())?;
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(&private_key)?,
);
write_owner_only(&ssh_command, script.as_bytes())?;
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(
access: &RepositorySshMaterializationAccess,
) -> 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()
|| access.credential_revision == 0
|| access.host_trust_id.trim().is_empty()
@@ -1109,6 +1212,10 @@ fn validate_ssh_materialization_access(
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(
request: &WorkingDirectoryRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
@@ -1120,6 +1227,7 @@ fn validate_remote_source_uri(
})?;
let expected_scheme = match request.repository.source.kind {
workspace_api::RepositorySourceKind::Https => "https",
workspace_api::RepositorySourceKind::Http => "http",
workspace_api::RepositorySourceKind::Ssh => "ssh",
_ => return Ok(()),
};
@@ -1128,7 +1236,7 @@ fn validate_remote_source_uri(
|| url.password().is_some()
|| !url.query().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(
"working_directory_repository_source_invalid",
@@ -1223,7 +1331,9 @@ fn repository_git_command(
};
command.args(["-c", &format!("protocol.file.allow={file_policy}")]);
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
}
@@ -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(
repository_cache: &Path,
) -> Result<(), WorkingDirectoryDiagnostic> {
@@ -1693,6 +1854,21 @@ mod tests {
local.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!(
fs::read_dir(runtime_root.path().join(REPOSITORY_CACHE_DIR))
.unwrap()
@@ -1754,6 +1930,7 @@ mod tests {
host_trust_id: "trust-1".to_string(),
host_trust_revision: 4,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
private_key: crate::catalog::SensitiveString::new("PRIVATE 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_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadWrite,
expires_at_epoch_seconds: u64::MAX,
private_key: crate::catalog::SensitiveString::new(
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 id = created.working_directory.id;
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 socket = PathBuf::from(binding.command_environment()["SSH_AUTH_SOCK"].clone());
assert!(socket.exists());
@@ -1811,6 +2014,40 @@ mod tests {
);
drop(binding);
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());
assert_eq!(
restored.bind_working_directory(&id, None).unwrap_err().code,
@@ -1837,6 +2074,7 @@ mod tests {
host_trust_id: "trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
known_hosts_entry: crate::catalog::SensitiveString::new(
"example.test ssh-ed25519 placeholder",
@@ -1902,6 +2140,18 @@ mod tests {
"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());
ssh.repository.source = workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
@@ -1914,6 +2164,7 @@ mod tests {
host_trust_id: "trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
known_hosts_entry: crate::catalog::SensitiveString::new(
"other.test ssh-ed25519 placeholder",
+47 -7
View File
@@ -25,8 +25,9 @@ use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest,
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef,
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkingDirectorySummary, WorkspaceApiRef,
};
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
#[cfg(test)]
@@ -39,11 +40,11 @@ use worker_runtime::execution::WorkerExecutionRunState;
use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerCompletionsRequest,
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse,
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse,
RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse,
RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse,
RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
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> {
RuntimeList::new(Vec::new(), Vec::new())
}
@@ -1427,6 +1438,23 @@ impl RuntimeRegistry {
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(
&self,
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> {
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
+74
View File
@@ -1437,6 +1437,18 @@ impl WorkspaceApi {
self.validate_worker_spawn_repository_scope(&request)?;
let workspace_api = self.workspace_api_ref(runtime_id);
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 =
request
.resolved_working_directory
@@ -1652,6 +1664,22 @@ impl WorkspaceApi {
&self,
worker: &RuntimeWorkerRef,
) -> 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
.runtime
.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_revision: lease.host_trust_revision,
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()),
known_hosts_entry: SensitiveString::new(lease.known_hosts_entry),
})
@@ -13985,6 +14018,47 @@ fn authorize_repository_materialization(
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(
api: &WorkspaceApi,
request: BrowserWorkingDirectoryCreateRequest,