diff --git a/crates/worker-runtime/src/auth.rs b/crates/worker-runtime/src/auth.rs index 15dc0fb2..bf02b35f 100644 --- a/crates/worker-runtime/src/auth.rs +++ b/crates/worker-runtime/src/auth.rs @@ -3,6 +3,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use ring::rand::{SecureRandom, SystemRandom}; use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::fmt; use std::time::{SystemTime, UNIX_EPOCH}; @@ -14,6 +15,11 @@ pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-pro const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1"; const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1."; pub const WORKER_REMOVE_PERMISSION: &str = "workspace:worker-remove"; +pub const RUNTIME_REQUEST_SOURCE_PROOF_HEADER: &str = "x-yoi-runtime-request-proof"; +pub const WORKSPACE_REQUEST_PERMISSION: &str = "workspace:request"; +pub const BACKEND_RESOURCE_FETCH_PERMISSION: &str = "workspace:resource-fetch"; +const RUNTIME_REQUEST_SOURCE_PROOF_PREFIX: &str = "yoi-runtime-request-v1"; +const RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-runtime-request-v1."; #[derive(Debug, thiserror::Error)] pub enum RuntimeAuthError { @@ -33,6 +39,10 @@ pub enum RuntimeAuthError { InvalidTokenFormat, #[error("malformed capability token claims: {0}")] MalformedClaims(#[from] serde_json::Error), + #[error("runtime request proof contains an invalid `{0}` claim")] + InvalidClaim(&'static str), + #[error("runtime request proof does not match the HTTP request")] + ClaimMismatch, #[error("unknown token issuer `{0}`")] UnknownIssuer(String), #[error("invalid token signature")] @@ -224,6 +234,162 @@ pub fn verify_capability_token( }) } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeRequestSourceClaims { + pub iss: String, + pub aud: String, + pub workspace_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + pub permission: String, + pub method: String, + pub path: String, + pub body_digest: String, + pub iat: i64, + pub exp: i64, + pub jti: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeRequestSourceSigner { + identity_id: String, + private_key: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeRequestSourceExpectation<'a> { + pub identity_id: &'a str, + pub audience: &'a str, + pub workspace_id: &'a str, + pub worker_id: Option<&'a str>, + pub permission: &'a str, + pub method: &'a str, + pub path: &'a str, + pub body_digest: &'a str, + pub now_unix: i64, +} + +pub fn request_body_digest(body: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(body)) +} + +impl RuntimeRequestSourceSigner { + pub fn from_identity(identity: &RuntimeIdentityMaterial) -> Self { + Self { + identity_id: identity.identity_id.clone(), + private_key: identity.private_key.clone(), + } + } + + #[allow(clippy::too_many_arguments)] + pub fn issue( + &self, + audience: &str, + workspace_id: &str, + worker_id: Option<&str>, + permission: &str, + method: &str, + path: &str, + body: &[u8], + now_unix: i64, + ttl_seconds: u64, + ) -> Result { + for (name, value) in [ + ("audience", audience), + ("workspace_id", workspace_id), + ("permission", permission), + ("method", method), + ("path", path), + ] { + if value.trim().is_empty() { + return Err(RuntimeAuthError::InvalidClaim(name)); + } + } + if worker_id.is_some_and(str::is_empty) { + return Err(RuntimeAuthError::InvalidClaim("worker_id")); + } + let ttl_seconds = i64::try_from(ttl_seconds).unwrap_or(i64::MAX); + let claims = RuntimeRequestSourceClaims { + iss: self.identity_id.clone(), + aud: audience.to_owned(), + workspace_id: workspace_id.to_owned(), + worker_id: worker_id.map(str::to_owned), + permission: permission.to_owned(), + method: method.to_owned(), + path: path.to_owned(), + body_digest: request_body_digest(body), + iat: now_unix, + exp: now_unix.saturating_add(ttl_seconds), + jti: new_token_id()?, + }; + let payload = serde_json::to_vec(&claims)?; + let payload = URL_SAFE_NO_PAD.encode(payload); + let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}"); + let private = decode_private_key(&self.private_key)?; + let key_pair = Ed25519KeyPair::from_pkcs8(&private) + .map_err(|_| RuntimeAuthError::InvalidPrivateKey)?; + let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref()); + Ok(format!( + "{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}" + )) + } +} + +pub fn decode_runtime_request_source_claims( + proof: &str, +) -> Result { + let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?; + if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX { + return Err(RuntimeAuthError::InvalidTokenFormat); + } + let payload = URL_SAFE_NO_PAD.decode(payload)?; + serde_json::from_slice(&payload).map_err(RuntimeAuthError::from) +} + +pub fn verify_runtime_request_source( + proof: &str, + public_key: &str, + expected: &RuntimeRequestSourceExpectation<'_>, +) -> Result { + let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?; + if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX { + return Err(RuntimeAuthError::InvalidTokenFormat); + } + let signature = URL_SAFE_NO_PAD.decode(signature)?; + let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}"); + let public_key = decode_public_key(public_key)?; + UnparsedPublicKey::new(&ED25519, public_key) + .verify(signing_input.as_bytes(), &signature) + .map_err(|_| RuntimeAuthError::InvalidSignature)?; + let claims = decode_runtime_request_source_claims(proof)?; + if claims.iss != expected.identity_id + || claims.aud != expected.audience + || claims.workspace_id != expected.workspace_id + || claims.worker_id.as_deref() != expected.worker_id + || claims.permission != expected.permission + || claims.method != expected.method + || claims.path != expected.path + || claims.body_digest != expected.body_digest + { + return Err(RuntimeAuthError::ClaimMismatch); + } + if claims.iat > expected.now_unix || claims.exp < expected.now_unix { + return Err(RuntimeAuthError::Expired); + } + Ok(claims) +} + +fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> { + let mut parts = proof.split('.'); + let prefix = parts.next().unwrap_or_default(); + let payload = parts.next().unwrap_or_default(); + let signature = parts.next().unwrap_or_default(); + if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() { + return Err(RuntimeAuthError::InvalidTokenFormat); + } + Ok((prefix, payload, signature)) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkerMutationSourceClaims { pub iss: String, @@ -592,6 +758,99 @@ mod tests { )); } + #[test] + fn runtime_request_source_proof_binds_request_and_rejects_spoofed_signature() { + let trusted = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); + let signer = RuntimeRequestSourceSigner::from_identity(&trusted); + let body = br#"{"ticket":"T-1"}"#; + let proof = signer + .issue( + "server-main", + "workspace-a", + Some("worker-7"), + WORKSPACE_REQUEST_PERMISSION, + "POST", + "/api/w/workspace-a/tickets/comment", + body, + 90, + 10, + ) + .unwrap(); + let expected = RuntimeRequestSourceExpectation { + identity_id: "runtime-main", + audience: "server-main", + workspace_id: "workspace-a", + worker_id: Some("worker-7"), + permission: WORKSPACE_REQUEST_PERMISSION, + method: "POST", + path: "/api/w/workspace-a/tickets/comment", + body_digest: &request_body_digest(body), + now_unix: 99, + }; + let claims = verify_runtime_request_source(&proof, &trusted.public_key, &expected).unwrap(); + assert_eq!(claims.iss, "runtime-main"); + let changed_body = RuntimeRequestSourceExpectation { + body_digest: &request_body_digest(br#"{"ticket":"T-2"}"#), + ..expected.clone() + }; + assert!(matches!( + verify_runtime_request_source(&proof, &trusted.public_key, &changed_body), + Err(RuntimeAuthError::ClaimMismatch) + )); + let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); + assert!(matches!( + verify_runtime_request_source(&proof, &spoofed.public_key, &expected), + Err(RuntimeAuthError::InvalidSignature) + )); + } + + #[test] + fn runtime_request_source_proof_rejects_wrong_scope_and_expiry() { + let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); + let proof = RuntimeRequestSourceSigner::from_identity(&runtime) + .issue( + "server-main", + "workspace-a", + None, + BACKEND_RESOURCE_FETCH_PERMISSION, + "POST", + "/api/runtime/v1/workspaces/workspace-a/resources/fetch", + b"{}", + 90, + 10, + ) + .unwrap(); + let digest = request_body_digest(b"{}"); + let expected = RuntimeRequestSourceExpectation { + identity_id: "runtime-main", + audience: "server-main", + workspace_id: "workspace-a", + worker_id: None, + permission: BACKEND_RESOURCE_FETCH_PERMISSION, + method: "POST", + path: "/api/runtime/v1/workspaces/workspace-a/resources/fetch", + body_digest: &digest, + now_unix: 99, + }; + assert!(verify_runtime_request_source(&proof, &runtime.public_key, &expected).is_ok()); + let wrong_workspace = RuntimeRequestSourceExpectation { + workspace_id: "workspace-b", + ..expected.clone() + }; + assert!(matches!( + verify_runtime_request_source(&proof, &runtime.public_key, &wrong_workspace), + Err(RuntimeAuthError::ClaimMismatch) + )); + let expired = RuntimeRequestSourceExpectation { + now_unix: 101, + ..expected + }; + assert!(matches!( + verify_runtime_request_source(&proof, &runtime.public_key, &expired), + Err(RuntimeAuthError::Expired) + )); + } + #[test] fn capability_token_verifies_signature_audience_expiry_and_permission() { let server = RuntimeIdentityMaterial::generate("server-main").unwrap(); diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index a8cb5fb9..3cd733f2 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -160,15 +160,33 @@ fn build_runtime(config: &ProcessConfig) -> Result { }; let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root")) .with_runtime_store_dir(runtime_store_dir); - if let Some(identity) = read_runtime_auth_file(&runtime_auth_path(config))?.identity { - factory = factory.with_remote_worker_mutation_identity(identity); + let runtime_auth = read_runtime_auth_file(&runtime_auth_path(config))?; + if let Some(identity) = runtime_auth.identity.clone() { + if let [trusted_server] = runtime_auth.trusted_servers.as_slice() { + factory = + factory.with_runtime_request_identity(identity, trusted_server.server_id.clone()); + } else { + factory = factory.with_remote_worker_mutation_identity(identity); + } } if let Some(endpoint) = config.backend_resource_endpoint.clone() { + let identity = runtime_auth.identity.as_ref().ok_or_else(|| { + ProcessError::Auth( + "--backend-resource-endpoint requires a configured Runtime identity".to_owned(), + ) + })?; + let [trusted_server] = runtime_auth.trusted_servers.as_slice() else { + return Err(ProcessError::Auth( + "--backend-resource-endpoint requires exactly one trusted Server identity" + .to_owned(), + )); + }; factory = factory.with_resource_client(Arc::new( worker_runtime::resource::HttpBackendResourceClient::new( endpoint, config.backend_resource_token.clone(), - ), + ) + .with_runtime_request_source(identity, trusted_server.server_id.clone()), )); } let backend = Arc::new( diff --git a/crates/worker-runtime/src/resource.rs b/crates/worker-runtime/src/resource.rs index 2813fe1a..60046fab 100644 --- a/crates/worker-runtime/src/resource.rs +++ b/crates/worker-runtime/src/resource.rs @@ -1,3 +1,7 @@ +use crate::auth::{ + BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds, +}; use crate::identity::WorkerId; use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex}; use async_trait::async_trait; @@ -108,6 +112,8 @@ pub trait BackendResourceClient: Send + Sync + 'static { pub struct HttpBackendResourceClient { endpoint: String, bearer_token: Option, + request_source_signer: Option, + request_source_audience: Option, client: reqwest::Client, } @@ -117,9 +123,21 @@ impl HttpBackendResourceClient { Self { endpoint: endpoint.into(), bearer_token, + request_source_signer: None, + request_source_audience: None, client: reqwest::Client::new(), } } + + pub fn with_runtime_request_source( + mut self, + identity: &RuntimeIdentityMaterial, + audience: impl Into, + ) -> Self { + self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity)); + self.request_source_audience = Some(audience.into()); + self + } } #[cfg(feature = "http-server")] @@ -129,7 +147,44 @@ impl BackendResourceClient for HttpBackendResourceClient { &self, request: BackendResourceFetchRequest, ) -> Result { - let builder = self.client.post(&self.endpoint).json(&request); + let body = serde_json::to_vec(&request).map_err(|error| { + BackendResourceError::InvalidResponse { + message: error.to_string(), + } + })?; + let endpoint = reqwest::Url::parse(&self.endpoint).map_err(|error| { + BackendResourceError::Transport { + message: error.to_string(), + } + })?; + let mut builder = self + .client + .post(endpoint.clone()) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body.clone()); + if let Some(signer) = self.request_source_signer.as_ref() { + let audience = self.request_source_audience.as_deref().ok_or_else(|| { + BackendResourceError::Unauthorized { + message: "Runtime request proof audience is unavailable".to_owned(), + } + })?; + let proof = signer + .issue( + audience, + &request.handle.workspace_id, + None, + BACKEND_RESOURCE_FETCH_PERMISSION, + "POST", + endpoint.path(), + &body, + i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), + 30, + ) + .map_err(|error| BackendResourceError::Unauthorized { + message: error.to_string(), + })?; + builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof); + } let builder = if let Some(token) = self.bearer_token.as_deref() { builder.bearer_auth(token) } else { diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index d741e0e5..c6d69c6b 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -14,7 +14,10 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; -use crate::auth::RuntimeIdentityMaterial; +use crate::auth::{ + BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds, +}; use crate::catalog::{ CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource, WorkingDirectoryRequest, WorkingDirectoryStatus, @@ -295,6 +298,7 @@ pub struct ProfileRuntimeWorkerFactory { prompt_projection_cache: Arc, runtime_id: Option, worker_mutation_identity: Option, + runtime_request_audience: Option, embedded_worker_mutation_dispatcher: Option>, controller_transport: WorkerControllerTransport, } @@ -311,6 +315,7 @@ impl ProfileRuntimeWorkerFactory { prompt_projection_cache: Arc::new(WorkspacePromptProjectionCache::default()), runtime_id: None, worker_mutation_identity: None, + runtime_request_audience: None, embedded_worker_mutation_dispatcher: None, controller_transport: WorkerControllerTransport::UnixSocket, } @@ -331,6 +336,17 @@ impl ProfileRuntimeWorkerFactory { self } + pub fn with_runtime_request_identity( + mut self, + identity: RuntimeIdentityMaterial, + audience: impl Into, + ) -> Self { + self.runtime_id = Some(identity.identity_id.clone()); + self.worker_mutation_identity = Some(identity); + self.runtime_request_audience = Some(audience.into()); + self + } + pub fn with_embedded_worker_mutation_dispatcher( mut self, runtime_id: impl Into, @@ -457,13 +473,15 @@ impl ProfileRuntimeWorkerFactory { async fn resolve_profile_source_archive( &self, source: &ProfileSourceArchiveSource, + request_audience: Option<&str>, ) -> Result { match source { ProfileSourceArchiveSource::Embedded { archive } => archive .verify() .map_err(|err| format!("failed to verify embedded profile source archive: {err}")), ProfileSourceArchiveSource::Http { location } => { - self.fetch_profile_source_archive(location).await + self.fetch_profile_source_archive(location, request_audience) + .await } } } @@ -471,10 +489,18 @@ impl ProfileRuntimeWorkerFactory { async fn fetch_profile_source_archive( &self, location: &ProfileSourceArchiveHttpRef, + request_audience: Option<&str>, ) -> Result { if let Some(cached) = self.profile_archive_cache.get(&location.archive.digest) { - let response = - fetch_profile_source_archive_http(location, Some(&location.archive.digest)).await?; + let response = fetch_profile_source_archive_http( + location, + Some(&location.archive.digest), + self.worker_mutation_identity.as_ref(), + self.runtime_request_audience + .as_deref() + .or(request_audience), + ) + .await?; if let Some(fetched) = response { self.profile_archive_cache.insert(fetched.clone()); fetched.verify().map_err(|err| { @@ -486,12 +512,19 @@ impl ProfileRuntimeWorkerFactory { .map_err(|err| format!("failed to verify cached profile source archive: {err}")) } } else { - let archive = fetch_profile_source_archive_http(location, None) - .await? - .ok_or_else(|| { - "profile source archive HTTP revalidation returned 304 without a cached archive" - .to_string() - })?; + let archive = fetch_profile_source_archive_http( + location, + None, + self.worker_mutation_identity.as_ref(), + self.runtime_request_audience + .as_deref() + .or(request_audience), + ) + .await? + .ok_or_else(|| { + "profile source archive HTTP revalidation returned 304 without a cached archive" + .to_string() + })?; self.profile_archive_cache.insert(archive.clone()); archive .verify() @@ -527,6 +560,7 @@ impl RuntimeWorkspaceBackendRef { worker_ref: &WorkerRef, workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>, mutation_identity: Option<&RuntimeIdentityMaterial>, + runtime_request_audience: Option<&str>, embedded_dispatcher: Option<&Arc>, prompt_projection_cache: Option>, ) -> WorkerWorkspaceContext { @@ -546,6 +580,13 @@ impl RuntimeWorkspaceBackendRef { if let Some(cache) = prompt_projection_cache { client = client.with_prompt_projection_cache(cache); } + if let Some(identity) = mutation_identity { + let audience = runtime_request_audience + .or_else(|| workspace_scope.map(|scope| scope.server_id.as_str())); + if let Some(audience) = audience { + client = client.with_runtime_request_source(identity, audience.to_owned()); + } + } if let (Some(scope), Some(identity)) = (workspace_scope, mutation_identity) { client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote( identity, @@ -576,9 +617,40 @@ impl RuntimeWorkspaceBackendRef { async fn fetch_profile_source_archive_http( location: &ProfileSourceArchiveHttpRef, cached_digest: Option<&str>, + identity: Option<&RuntimeIdentityMaterial>, + audience: Option<&str>, ) -> Result, String> { let client = reqwest::Client::new(); - let mut request = client.get(&location.url); + let url = reqwest::Url::parse(&location.url) + .map_err(|error| format!("profile source archive URL is invalid: {error}"))?; + let path = url.path().to_owned(); + let workspace_id = path + .split('/') + .collect::>() + .windows(2) + .find_map(|parts| (parts[0] == "w").then_some(parts[1])) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "profile source archive URL is not workspace-scoped".to_owned())?; + let mut request = client.get(url); + if let Some(identity) = identity { + let audience = audience.ok_or_else(|| { + "profile source archive request proof audience is unavailable".to_owned() + })?; + let proof = RuntimeRequestSourceSigner::from_identity(identity) + .issue( + audience, + workspace_id, + None, + BACKEND_RESOURCE_FETCH_PERMISSION, + "GET", + &path, + b"", + i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), + 30, + ) + .map_err(|error| error.to_string())?; + request = request.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof); + } if cached_digest == Some(location.archive.digest.as_str()) { if let Some(etag) = location.etag.as_deref() { request = request.header(reqwest::header::IF_NONE_MATCH, etag); @@ -620,6 +692,8 @@ async fn fetch_profile_source_archive_http( async fn fetch_profile_source_archive_http( _location: &ProfileSourceArchiveHttpRef, _cached_digest: Option<&str>, + _identity: Option<&RuntimeIdentityMaterial>, + _audience: Option<&str>, ) -> Result, String> { Err( "HTTP profile source archive fetch requires the worker-runtime http-server feature" @@ -743,12 +817,19 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { &request.worker_ref, request.workspace_scope.as_ref(), self.worker_mutation_identity.as_ref(), + self.runtime_request_audience.as_deref(), self.embedded_worker_mutation_dispatcher.as_ref(), Some(self.prompt_projection_cache.clone()), ); let selector = profile.as_ref(); let archive = self - .resolve_profile_source_archive(&request.request.profile_source) + .resolve_profile_source_archive( + &request.request.profile_source, + request + .workspace_scope + .as_ref() + .map(|scope| scope.server_id.as_str()), + ) .await?; let (mut manifest, mut loader) = { let manifest = archive @@ -909,6 +990,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { &request.worker_ref, request.workspace_scope.as_ref(), self.worker_mutation_identity.as_ref(), + self.runtime_request_audience.as_deref(), self.embedded_worker_mutation_dispatcher.as_ref(), Some(self.prompt_projection_cache.clone()), ); @@ -2187,12 +2269,18 @@ mod tests { let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main"); let before_restart = - backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None); + backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None, None); let adapter = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap(); let (after_restore_kind, after_restore_workspace_id) = adapter .run_on_adapter_runtime(async move { - let after_restore = - backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None); + let after_restore = backend.worker_context( + &worker_ref, + Some(&scope), + Some(&identity), + None, + None, + None, + ); let client = after_restore.client_handle(); Ok(( client.kind().to_string(), @@ -2383,6 +2471,7 @@ mod tests { None, None, None, + None, ); let workspace_client = workspace_context.client_handle(); self.observed_workspace_clients.lock().unwrap().push(( @@ -2785,7 +2874,7 @@ mod tests { archive: bundle.profile_source_archive.clone().unwrap(), }; factory - .resolve_profile_source_archive(&source) + .resolve_profile_source_archive(&source, None) .await .expect("embedded archive should resolve without Backend resource client"); } diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index fbf6f328..68ea1845 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -7,8 +7,9 @@ use worker::{ }; use crate::auth::{ - RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner, - WORKER_REMOVE_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation, + RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial, + RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION, + WORKSPACE_REQUEST_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id, }; use crate::runtime::RuntimeWorkspaceScope; @@ -289,6 +290,8 @@ pub struct RuntimeOwnedWorkspaceClient { worker_id: String, request_timeout: Option, worker_remove: Option, + request_source_signer: Option, + request_source_audience: Option, prompt_projection_cache: Option>, } @@ -306,6 +309,8 @@ impl RuntimeOwnedWorkspaceClient { worker_id: worker_id.into(), request_timeout: None, worker_remove: None, + request_source_signer: None, + request_source_audience: None, prompt_projection_cache: None, } } @@ -315,6 +320,16 @@ impl RuntimeOwnedWorkspaceClient { self } + pub fn with_runtime_request_source( + mut self, + identity: &RuntimeIdentityMaterial, + audience: impl Into, + ) -> Self { + self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity)); + self.request_source_audience = Some(audience.into()); + self + } + pub(crate) fn with_prompt_projection_cache( mut self, cache: Arc, @@ -363,15 +378,21 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { request: WorkspaceRequest, ) -> Result { let base_url = self.base_url.clone(); + let workspace_id = self.workspace_id.clone(); let runtime_id = self.runtime_id.clone(); let worker_id = self.worker_id.clone(); + let request_source_signer = self.request_source_signer.clone(); + let request_source_audience = self.request_source_audience.clone(); let request_timeout = self.request_timeout; if tokio::runtime::Handle::try_current().is_ok() { std::thread::spawn(move || { execute_runtime_owned_workspace_http( &base_url, + &workspace_id, &runtime_id, &worker_id, + request_source_signer.as_ref(), + request_source_audience.as_deref(), request_timeout, request, ) @@ -383,8 +404,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { } else { execute_runtime_owned_workspace_http( &self.base_url, + &self.workspace_id, &self.runtime_id, &self.worker_id, + self.request_source_signer.as_ref(), + self.request_source_audience.as_deref(), self.request_timeout, request, ) @@ -484,8 +508,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { fn execute_runtime_owned_workspace_http( base_url: &str, + workspace_id: &str, runtime_id: &str, worker_id: &str, + request_source_signer: Option<&RuntimeRequestSourceSigner>, + request_source_audience: Option<&str>, request_timeout: Option, request: WorkspaceRequest, ) -> Result { @@ -510,11 +537,33 @@ fn execute_runtime_owned_workspace_http( )) })?; let request_label = format!("{method} {}", request.path); + let body = request.body.unwrap_or_default(); let mut request_builder = client - .request(method, url) + .request(method.clone(), url) .header("x-yoi-runtime-id", runtime_id) .header("x-yoi-worker-id", worker_id); - if let Some(body) = request.body { + if let Some(signer) = request_source_signer { + let audience = request_source_audience.ok_or_else(|| { + WorkspaceClientError::Request( + "runtime request proof audience is unavailable".to_owned(), + ) + })?; + let proof = signer + .issue( + audience, + workspace_id, + Some(worker_id), + WORKSPACE_REQUEST_PERMISSION, + method.as_str(), + &request.path, + body.as_bytes(), + i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), + 30, + ) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?; + request_builder = request_builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof); + } + if !body.is_empty() { request_builder = request_builder .header(reqwest::header::CONTENT_TYPE, "application/json") .body(body); @@ -797,7 +846,7 @@ mod tests { } #[test] - fn ordinary_workspace_forwarding_stamps_legacy_source_only_inside_runtime() { + fn ordinary_workspace_forwarding_stamps_runtime_identity_and_signed_source_proof() { use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::Mutex; @@ -817,12 +866,14 @@ mod tests { .unwrap(); }); + let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap(); let client = RuntimeOwnedWorkspaceClient::new( "workspace-a", format!("http://{address}"), "runtime-a", "worker-a", - ); + ) + .with_runtime_request_source(&identity, "server-a"); let response = client .execute(WorkspaceRequest::get("/api/w/workspace-a/tickets/search")) .unwrap(); @@ -831,6 +882,7 @@ mod tests { let request = received.lock().unwrap().to_ascii_lowercase(); assert!(request.contains("x-yoi-runtime-id: runtime-a")); assert!(request.contains("x-yoi-worker-id: worker-a")); + assert!(request.contains("x-yoi-runtime-request-proof: yoi-runtime-request-v1.")); assert!(!request.contains("authorization:")); } diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index 6a561892..ae43d57a 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -176,8 +176,28 @@ fn actor_for_user( })) } -pub fn session_set_cookie(cookie_name: &str, token: &str, max_age_seconds: i64) -> String { - format!("{cookie_name}={token}; Max-Age={max_age_seconds}; Path=/; HttpOnly; SameSite=Lax") +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionCookiePolicy<'a> { + pub cookie_name: &'a str, + pub path: &'a str, + pub domain: Option<&'a str>, + pub secure: bool, +} + +pub fn session_set_cookie( + policy: SessionCookiePolicy<'_>, + token: &str, + max_age_seconds: i64, +) -> String { + let domain = policy + .domain + .map(|domain| format!("; Domain={domain}")) + .unwrap_or_default(); + let secure = if policy.secure { "; Secure" } else { "" }; + format!( + "{}={token}; Max-Age={max_age_seconds}; Path={}; HttpOnly; SameSite=Lax{domain}{secure}", + policy.cookie_name, policy.path + ) } pub fn auth_error(code: &str, message: &str) -> Error { diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 986fad7f..b92e33ae 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -63,9 +63,9 @@ use workspace_api::{ }; use crate::auth::{ - AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret, new_id, - new_user_code, normalize_handle, parse_cookie, resolve_request_actor, rfc3339_after, - session_set_cookie, token_hash, + ActorAuthMethod, AuthPublicConfig, AuthenticatedUser, RequestActor, SessionCookiePolicy, + auth_error, is_expired, mint_secret, new_id, new_user_code, normalize_handle, parse_cookie, + resolve_request_actor, rfc3339_after, session_set_cookie, token_hash, }; use crate::authority::{ MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput, @@ -330,6 +330,12 @@ fn repository_local_path(source: &workspace_api::RepositorySource) -> Option = std::sync::LazyLock::new(|| { + worker_runtime::auth::RuntimeIdentityMaterial::generate(EMBEDDED_RUNTIME_ID) + .expect("embedded Runtime request identity generation must succeed") +}); #[derive(Clone)] pub struct WorkspaceApi { @@ -793,7 +799,7 @@ impl WorkspaceServerApi { .for_catalog_workspace(&workspace, repositories)?; let api = WorkspaceApi::new(config, self.store.clone()).await?; tokio::spawn(run_orchestrator_turn_end_hook(api.clone())); - let router = build_router(api); + let router = build_inner_router(api); routers.insert(workspace_id.to_string(), router.clone()); Ok(Some(router)) } @@ -838,7 +844,11 @@ async fn list_server_workspaces( ) -> Response { let owner = match resolve_server_actor(&api, &headers).await { Ok(Some(actor)) => Some(actor.account_id), - Ok(None) => None, + Ok(None) => match api.catalog.list(None, 1) { + Ok(workspaces) if workspaces.is_empty() => return Json(workspaces).into_response(), + Ok(_) => return StatusCode::UNAUTHORIZED.into_response(), + Err(error) => return server_error_response(error), + }, Err(error) => return server_error_response(error), }; match api @@ -893,12 +903,199 @@ async fn resolve_server_actor( resolve_request_actor(api.store.as_ref(), headers, &cookie_name).await } -async fn dispatch_workspace_request( +async fn authorize_scoped_workspace_request( + api: &WorkspaceServerApi, + workspace_id: &str, + request: &mut Request, +) -> std::result::Result<(), Response> { + let proof = request + .headers() + .get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + if let Some(proof) = proof { + let method = request.method().as_str().to_owned(); + let path = request.uri().path().to_owned(); + let body = std::mem::take(request.body_mut()); + let body = axum::body::to_bytes(body, 16 * 1024 * 1024) + .await + .map_err(|_| StatusCode::BAD_REQUEST.into_response())?; + let digest = worker_runtime::auth::request_body_digest(&body); + *request.body_mut() = axum::body::Body::from(body); + let permission = if path.starts_with("/api/runtime/v1/workspaces/") + || path.contains("/profile-source-archives/") + { + worker_runtime::auth::BACKEND_RESOURCE_FETCH_PERMISSION + } else { + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION + }; + let source = crate::worker_source::verify_runtime_request_source_proof_with_store( + api.store.as_ref(), + api.template.as_ref(), + &proof, + workspace_id, + permission, + &method, + &path, + &digest, + ) + .await + .map_err(|_| StatusCode::UNAUTHORIZED.into_response())?; + request.extensions_mut().insert(source); + return Ok(()); + } + + let actor = resolve_server_actor(api, request.headers()) + .await + .map_err(server_error_response)? + .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; + + let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession); + let mutating = !matches!( + *request.method(), + Method::GET | Method::HEAD | Method::OPTIONS + ); + if cookie_authenticated && mutating { + let AuthConfig::Passkey { origin, .. } = &api.template.auth; + if origin + != request + .headers() + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + { + return Err(StatusCode::FORBIDDEN.into_response()); + } + } + Ok(()) +} + +async fn authorize_workspace_api_request( + State(api): State, + mut request: Request, + next: axum::middleware::Next, +) -> Response { + if !request.uri().path().starts_with("/api/") { + return next.run(request).await; + } + let public_server_api = is_server_global_forward(request.uri().path()); + let workspace_id = api.workspace_id().to_owned(); + let proof = request + .headers() + .get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + if let Some(proof) = proof { + let method = request.method().as_str().to_owned(); + let path = request.uri().path().to_owned(); + let body = std::mem::take(request.body_mut()); + let Ok(body) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else { + return StatusCode::BAD_REQUEST.into_response(); + }; + let digest = worker_runtime::auth::request_body_digest(&body); + *request.body_mut() = axum::body::Body::from(body); + let permission = if path.starts_with("/api/runtime/v1/workspaces/") + || path.contains("/profile-source-archives/") + { + worker_runtime::auth::BACKEND_RESOURCE_FETCH_PERMISSION + } else { + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION + }; + let Ok(source) = crate::worker_source::verify_runtime_request_source_proof( + &api, + &proof, + &workspace_id, + permission, + &method, + &path, + &digest, + ) + .await + else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + request.extensions_mut().insert(source); + return next.run(request).await; + } + + let AuthConfig::Passkey { cookie_name, .. } = &api.config.auth; + let actor = match crate::auth::resolve_request_actor( + api.store.as_ref(), + request.headers(), + cookie_name, + ) + .await + { + Ok(Some(actor)) => actor, + Ok(None) if public_server_api => return next.run(request).await, + Ok(None) | Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession); + let mutating = !matches!( + *request.method(), + Method::GET | Method::HEAD | Method::OPTIONS + ); + if cookie_authenticated && mutating { + let AuthConfig::Passkey { origin, .. } = &api.config.auth; + if origin + != request + .headers() + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + { + return StatusCode::FORBIDDEN.into_response(); + } + } + next.run(request).await +} + +async fn enforce_server_cookie_mutation_origin( State(api): State, request: Request, + next: axum::middleware::Next, ) -> Response { - let path = request.uri().path(); - let workspace_id = scoped_workspace_id(path); + if matches!( + *request.method(), + Method::GET | Method::HEAD | Method::OPTIONS + ) { + return next.run(request).await; + } + let actor = match resolve_server_actor(&api, request.headers()).await { + Ok(actor) => actor, + Err(error) => return server_error_response(error), + }; + if actor + .as_ref() + .is_some_and(|actor| matches!(actor.auth_method, ActorAuthMethod::BrowserSession)) + { + let AuthConfig::Passkey { origin, .. } = &api.template.auth; + let presented_origin = request + .headers() + .get(ORIGIN) + .and_then(|value| value.to_str().ok()); + if presented_origin != Some(origin.as_str()) { + return forbidden_server_response( + "cookie-authenticated mutations require the configured Browser origin", + ); + } + } + next.run(request).await +} + +async fn dispatch_workspace_request( + State(api): State, + mut request: Request, +) -> Response { + let path = request.uri().path().to_owned(); + let workspace_id = scoped_workspace_id(&path); + if let Some(workspace_id) = workspace_id + && (path.starts_with("/api/w/") || path.starts_with("/api/runtime/v1/workspaces/")) + && let Err(response) = + authorize_scoped_workspace_request(&api, workspace_id, &mut request).await + { + return response; + } let router = if let Some(workspace_id) = workspace_id { match api.router_for_workspace(workspace_id).await { Ok(Some(router)) => Some(router), @@ -910,15 +1107,28 @@ async fn dispatch_workspace_request( Ok(workspaces) => workspaces, Err(error) => return server_error_response(error), }; - if workspaces.is_empty() && is_server_static_forward(path) { - return serve_server_static_shell(&api, path).await; + if workspaces.is_empty() && is_server_static_forward(&path) { + return serve_server_static_shell(&api, &path).await; } - if workspaces.len() == 1 || is_server_global_forward(path) { + if workspaces.len() == 1 || is_server_global_forward(&path) { match workspaces.first() { - Some(workspace) => match api.router_for_workspace(&workspace.workspace_id).await { - Ok(router) => router, - Err(error) => return server_error_response(error), - }, + Some(workspace) => { + if path.starts_with("/api/") + && !is_server_global_forward(&path) + && let Err(response) = authorize_scoped_workspace_request( + &api, + &workspace.workspace_id, + &mut request, + ) + .await + { + return response; + } + match api.router_for_workspace(&workspace.workspace_id).await { + Ok(router) => router, + Err(error) => return server_error_response(error), + } + } None => None, } } else { @@ -955,6 +1165,12 @@ async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Resp } fn scoped_workspace_id(path: &str) -> Option<&str> { + if let Some(rest) = path.strip_prefix("/api/runtime/v1/workspaces/") { + return rest + .split('/') + .next() + .filter(|workspace_id| !workspace_id.is_empty()); + } let mut segments = path.trim_start_matches('/').split('/'); match (segments.next(), segments.next(), segments.next()) { (Some("api"), Some("w"), Some(workspace_id)) @@ -984,8 +1200,13 @@ pub async fn build_workspace_server_router( get(list_server_workspaces).post(create_server_workspace), ) .fallback(dispatch_workspace_request) - .with_state(api); - Ok(auth.merge(catalog)) + .with_state(api.clone()); + Ok(auth + .merge(catalog) + .layer(axum::middleware::from_fn_with_state( + api, + enforce_server_cookie_mutation_origin, + ))) } impl WorkspaceApi { @@ -999,6 +1220,20 @@ impl WorkspaceApi { pub async fn new(config: ServerConfig, store: Arc) -> Result { let resource_broker = BackendResourceBroker::default(); + let embedded_identity = (*EMBEDDED_RUNTIME_REQUEST_IDENTITY).clone(); + store + .upsert_trusted_runtime_record(&crate::store::TrustedRuntimeRecord { + runtime_id: EMBEDDED_RUNTIME_ID.to_owned(), + workspace_id: None, + display_name: "Embedded Runtime".to_owned(), + base_url: "in-process://embedded".to_owned(), + public_key: embedded_identity.public_key.clone(), + created_at: config.workspace_created_at.clone(), + updated_at: config.workspace_created_at.clone(), + revoked_at: None, + }) + .await?; + let embedded_audience = format!("embedded:{}", config.workspace_id); let worker_remove_dispatcher = Arc::new( crate::worker_source::EmbeddedServerWorkerMutationDispatcher::new( config.clone(), @@ -1011,6 +1246,7 @@ impl WorkspaceApi { EMBEDDED_RUNTIME_ID, worker_remove_dispatcher.clone(), ) + .with_runtime_request_identity(embedded_identity, embedded_audience) .with_runtime_store_dir(config.embedded_runtime_store_root.clone()) .with_controller_transport(worker::WorkerControllerTransport::InProcess) .with_resource_client(Arc::new(resource_broker.clone())), @@ -1603,7 +1839,7 @@ fn build_server_auth_router(api: ServerAuthApi) -> Router { .with_state(api) } -pub fn build_router(api: WorkspaceApi) -> Router { +fn build_inner_router(api: WorkspaceApi) -> Router { let auth = build_server_auth_router(ServerAuthApi::from(&api)); let scoped_ticket_relations_query_path = format!("/api/w/{{workspace_id}}{TICKET_RELATIONS_QUERY_PATH}"); @@ -2019,7 +2255,7 @@ pub fn build_router(api: WorkspaceApi) -> Router { post(scoped_test_remote_runtime_connection), ) .route( - "/internal/w/{workspace_id}/runtime/resources/fetch", + "/api/runtime/v1/workspaces/{workspace_id}/resources/fetch", post(scoped_post_internal_runtime_resource_fetch), ) .route("/api/companion/status", get(get_companion_status)) @@ -2215,6 +2451,13 @@ pub async fn serve_workspace_catalog( Ok(()) } +pub fn build_router(api: WorkspaceApi) -> Router { + build_inner_router(api.clone()).layer(axum::middleware::from_fn_with_state( + api, + authorize_workspace_api_request, + )) +} + pub async fn serve( config: ServerConfig, store: Arc, @@ -9342,18 +9585,22 @@ fn issue_browser_session_response(api: &ServerAuthApi, user: UserRecord) -> ApiR expires_at: rfc3339_after(Duration::days(14)), revoked_at: None, })?; - let cookie_name = auth_public_config(&api.config).cookie_name; + let auth = auth_public_config(&api.config); let mut headers = HeaderMap::new(); headers.insert( SET_COOKIE, - session_set_cookie(&cookie_name, &session_token, 14 * 24 * 60 * 60) - .parse() - .map_err(|error| { - auth_error( - "invalid_session_cookie", - &format!("failed to build session cookie: {error}"), - ) - })?, + session_set_cookie( + session_cookie_policy(&auth), + &session_token, + 14 * 24 * 60 * 60, + ) + .parse() + .map_err(|error| { + auth_error( + "invalid_session_cookie", + &format!("failed to build session cookie: {error}"), + ) + })?, ); Ok(( headers, @@ -9522,7 +9769,7 @@ async fn post_auth_logout( let mut response_headers = HeaderMap::new(); response_headers.insert( SET_COOKIE, - session_set_cookie(&auth.cookie_name, "", 0) + session_set_cookie(session_cookie_policy(&auth), "", 0) .parse() .map_err(|error| { auth_error( @@ -9624,6 +9871,19 @@ fn passkey_credential_id(passkey: &Passkey) -> ApiResult { }) } +fn session_cookie_policy(auth: &AuthPublicConfig) -> SessionCookiePolicy<'_> { + let secure = [&auth.origin, &auth.public_base_url] + .into_iter() + .filter_map(|url| reqwest::Url::parse(url).ok()) + .any(|url| url.scheme() == "https"); + SessionCookiePolicy { + cookie_name: &auth.cookie_name, + path: "/", + domain: None, + secure, + } +} + fn auth_public_config(config: &ServerConfig) -> AuthPublicConfig { match &config.auth { AuthConfig::Passkey { @@ -10562,7 +10822,8 @@ fn browser_worker_response_from_summary( async fn scoped_post_internal_runtime_resource_fetch( State(api): State, AxumPath(workspace_id): AxumPath, - Json(request): Json, + headers: HeaderMap, + request: Request, ) -> std::result::Result< Json, (StatusCode, Json), @@ -10573,6 +10834,80 @@ async fn scoped_post_internal_runtime_resource_fetch( Json(BackendResourceError::MissingResource), )); } + let proof = headers + .get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(BackendResourceError::Unauthorized { + message: "Runtime request proof is required".to_owned(), + }), + ) + })?; + let verified_source = request + .extensions() + .get::() + .cloned(); + let method = request.method().as_str().to_owned(); + let path = request.uri().path().to_owned(); + let body = axum::body::to_bytes(request.into_body(), 16 * 1024 * 1024) + .await + .map_err(|error| { + ( + StatusCode::BAD_REQUEST, + Json(BackendResourceError::InvalidResponse { + message: error.to_string(), + }), + ) + })?; + let source = if let Some(source) = verified_source { + source + } else { + crate::worker_source::verify_runtime_request_source_proof( + &api, + proof, + &workspace_id, + worker_runtime::auth::BACKEND_RESOURCE_FETCH_PERMISSION, + &method, + &path, + &worker_runtime::auth::request_body_digest(&body), + ) + .await + .map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(BackendResourceError::Unauthorized { + message: "Runtime request proof is invalid".to_owned(), + }), + ) + })? + }; + if source.worker_id.is_some() { + return Err(( + StatusCode::UNAUTHORIZED, + Json(BackendResourceError::Unauthorized { + message: "Worker-scoped proof cannot fetch Runtime resources".to_owned(), + }), + )); + } + let request: BackendResourceFetchRequest = serde_json::from_slice(&body).map_err(|error| { + ( + StatusCode::BAD_REQUEST, + Json(BackendResourceError::InvalidResponse { + message: error.to_string(), + }), + ) + })?; + if request.runtime_id != source.runtime_id { + return Err(( + StatusCode::UNAUTHORIZED, + Json(BackendResourceError::Unauthorized { + message: "Runtime request proof subject does not match the request".to_owned(), + }), + )); + } api.resource_broker .fetch_profile_source_archive(request) .map(Json) @@ -13698,10 +14033,133 @@ mod tests { WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, }; use crate::store::{ - MemoryDocumentRecord, MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord, - ObjectiveTicketLinkRecord, SqliteWorkspaceStore, WorkspaceRecord, + AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord, + MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord, ObjectiveTicketLinkRecord, + SqliteWorkspaceStore, TrustedRuntimeRecord, UserRecord, WorkspaceRecord, }; + fn seed_test_api_token(store: &dyn ControlPlaneStore, suffix: &str) -> String { + let account_id = format!("account-{suffix}"); + let user_id = format!("user-{suffix}"); + let token = format!("api-token-{suffix}"); + store + .upsert_account(&AccountRecord { + account_id: account_id.clone(), + kind: "user".to_owned(), + handle: format!("user-{suffix}"), + display_name: "Test User".to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + updated_at: "2026-01-01T00:00:00Z".to_owned(), + }) + .unwrap(); + store + .upsert_user(&UserRecord { + user_id: user_id.clone(), + account_id, + handle: format!("user-{suffix}"), + display_name: "Test User".to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + updated_at: "2026-01-01T00:00:00Z".to_owned(), + }) + .unwrap(); + store + .create_api_token(&ApiTokenRecord { + token_hash: crate::auth::token_hash(&token), + token_id: format!("token-{suffix}"), + user_id, + label: "test".to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + expires_at: None, + last_used_at: None, + revoked_at: None, + }) + .unwrap(); + token + } + + fn configure_runtime_request_auth( + api: &mut WorkspaceApi, + identity: &worker_runtime::auth::RuntimeIdentityMaterial, + runtime_id: &str, + ) { + api.config.remote_runtime_sources.push(RemoteRuntimeConfig { + runtime_id: runtime_id.to_owned(), + workspace_id: Some(api.workspace_id().to_owned()), + display_name: runtime_id.to_owned(), + base_url: "https://runtime.test".to_owned(), + bearer_token: None, + auth: Some(RemoteRuntimeAuthConfig { + server_id: "server-test".to_owned(), + server_private_key: "unused".to_owned(), + }), + cached_capabilities: RuntimeCapabilitySummary { + can_list_hosts: true, + can_list_workers: true, + can_get_worker: true, + can_spawn_worker: true, + can_stop_worker: true, + has_workspace_fs: false, + has_shell: false, + has_git: false, + supports_worktrees: false, + supports_backend_internal_tools: false, + workspace_scope: api.workspace_id().to_owned(), + max_workers: 1, + os: "test".to_owned(), + arch: "test".to_owned(), + }, + cached_status: "connected".to_owned(), + timeout: std::time::Duration::from_secs(1), + }); + SqliteWorkspaceStore::open(&api.config.database_path) + .unwrap() + .upsert_trusted_runtime(&TrustedRuntimeRecord { + runtime_id: runtime_id.to_owned(), + workspace_id: Some(api.workspace_id().to_owned()), + display_name: runtime_id.to_owned(), + base_url: "https://runtime.test".to_owned(), + public_key: identity.public_key.clone(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + updated_at: "2026-01-01T00:00:00Z".to_owned(), + revoked_at: None, + }) + .unwrap(); + } + + fn runtime_resource_fetch_request( + api: &WorkspaceApi, + identity: &worker_runtime::auth::RuntimeIdentityMaterial, + body: Vec, + ) -> Request { + let path = format!( + "/api/runtime/v1/workspaces/{}/resources/fetch", + api.workspace_id() + ); + let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(identity) + .issue( + "server-test", + api.workspace_id(), + None, + worker_runtime::auth::BACKEND_RESOURCE_FETCH_PERMISSION, + "POST", + &path, + &body, + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap_or(i64::MAX), + 30, + ) + .unwrap(); + Request::builder() + .method("POST") + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ) + .body(Body::from(body)) + .unwrap() + } + fn test_create_binding() -> WorkerCreateBinding { WorkerCreateBinding { worker_id: WorkerId::now_v7(), @@ -15426,6 +15884,23 @@ mod tests { let app = build_workspace_server_router(template, store) .await .unwrap(); + let empty_catalog = app + .clone() + .oneshot( + Request::builder() + .uri("/api/workspaces") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(empty_catalog.status(), StatusCode::OK); + assert_eq!( + to_bytes(empty_catalog.into_body(), usize::MAX) + .await + .unwrap(), + "[]" + ); for (uri, expected) in [ ("/", "
Workspace chooser
"), @@ -15484,6 +15959,380 @@ mod tests { assert!(auth["cookie_name"].is_string()); } + #[tokio::test] + async fn workspace_server_router_requires_identity_for_scoped_rest() { + let temp = tempfile::tempdir().unwrap(); + let config = test_server_config(temp.path()); + let AuthConfig::Passkey { + origin: expected_origin, + .. + } = &config.auth; + let expected_origin = expected_origin.clone(); + let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let catalog = WorkspaceCatalogService::new(store.clone()); + let repository = temp.path().join("repository"); + std::fs::create_dir_all(&repository).unwrap(); + assert!( + std::process::Command::new("git") + .args(["init", "-q"]) + .current_dir(&repository) + .status() + .unwrap() + .success() + ); + let workspace = catalog + .create( + WorkspaceCreateRequest { + operation_key: "create-auth".to_owned(), + display_name: "Auth Workspace".to_owned(), + repository: crate::workspace_catalog::InitialRepositoryIntent { + uri: repository.display().to_string(), + display_name: None, + default_ref: None, + }, + }, + None, + ) + .unwrap(); + store + .upsert_account(&AccountRecord { + account_id: "account-auth".to_owned(), + kind: "user".to_owned(), + handle: "auth-user".to_owned(), + display_name: "Auth User".to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + updated_at: "2026-01-01T00:00:00Z".to_owned(), + }) + .unwrap(); + store + .upsert_user(&UserRecord { + user_id: "user-auth".to_owned(), + account_id: "account-auth".to_owned(), + handle: "auth-user".to_owned(), + display_name: "Auth User".to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + updated_at: "2026-01-01T00:00:00Z".to_owned(), + }) + .unwrap(); + store + .create_api_token(&ApiTokenRecord { + token_hash: crate::auth::token_hash("api-token-auth"), + token_id: "token-auth".to_owned(), + user_id: "user-auth".to_owned(), + label: "test".to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + expires_at: None, + last_used_at: None, + revoked_at: None, + }) + .unwrap(); + store + .create_browser_session(&BrowserSessionRecord { + token_hash: crate::auth::token_hash("browser-session-auth"), + session_id: "session-auth".to_owned(), + user_id: "user-auth".to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + expires_at: "2099-01-01T00:00:00Z".to_owned(), + revoked_at: None, + }) + .unwrap(); + let app = build_workspace_server_router(config, store).await.unwrap(); + let uri = format!("/api/w/{}/workspace", workspace.workspace.workspace_id); + + let anonymous = app + .clone() + .oneshot(Request::builder().uri(&uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED); + + for legacy_path in ["/api/workspace", "/api/runtimes", "/api/tickets"] { + let anonymous_legacy = app + .clone() + .oneshot( + Request::builder() + .uri(legacy_path) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + anonymous_legacy.status(), + StatusCode::UNAUTHORIZED, + "{legacy_path} must not bypass Workspace auth" + ); + } + let authenticated_legacy = app + .clone() + .oneshot( + Request::builder() + .uri("/api/workspace") + .header(axum::http::header::AUTHORIZATION, "Bearer api-token-auth") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(authenticated_legacy.status(), StatusCode::OK); + + let anonymous_catalog = app + .clone() + .oneshot( + Request::builder() + .uri("/api/workspaces") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous_catalog.status(), StatusCode::UNAUTHORIZED); + let authenticated_catalog = app + .clone() + .oneshot( + Request::builder() + .uri("/api/workspaces") + .header(axum::http::header::AUTHORIZATION, "Bearer api-token-auth") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(authenticated_catalog.status(), StatusCode::OK); + + for path in ["/api/workspaces", "/api/auth/device-login/approve"] { + let cross_site = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(path) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + cross_site.status(), + StatusCode::FORBIDDEN, + "{path} must reject a cookie-authenticated cross-site mutation" + ); + let same_origin = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(path) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, expected_origin.as_str()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!( + same_origin.status(), + StatusCode::FORBIDDEN, + "{path} must accept the configured Browser origin" + ); + } + + let ws_uri = format!("/api/w/{}/protocol/ws", workspace.workspace.workspace_id); + let anonymous_ws = app + .clone() + .oneshot(Request::builder().uri(&ws_uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(anonymous_ws.status(), StatusCode::UNAUTHORIZED); + let authenticated_ws = app + .clone() + .oneshot( + Request::builder() + .uri(ws_uri) + .header(axum::http::header::AUTHORIZATION, "Bearer api-token-auth") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(authenticated_ws.status(), StatusCode::UNAUTHORIZED); + + let authenticated = app + .clone() + .oneshot( + Request::builder() + .uri(&uri) + .header(axum::http::header::AUTHORIZATION, "Bearer api-token-auth") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(authenticated.status(), StatusCode::OK); + + let settings_uri = format!( + "/api/w/{}/settings/workspace", + workspace.workspace.workspace_id + ); + let csrf_rejected = app + .clone() + .oneshot( + Request::builder() + .method(Method::PUT) + .uri(&settings_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"display_name":"Renamed"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(csrf_rejected.status(), StatusCode::FORBIDDEN); + + let mixed_auth_csrf_rejected = app + .clone() + .oneshot( + Request::builder() + .method(Method::PUT) + .uri(&settings_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(axum::http::header::AUTHORIZATION, "Bearer invalid-token") + .header(CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"display_name":"Renamed"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(mixed_auth_csrf_rejected.status(), StatusCode::FORBIDDEN); + + let csrf_accepted = app + .oneshot( + Request::builder() + .method(Method::PUT) + .uri(settings_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, expected_origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"display_name":"Renamed"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn direct_workspace_router_enforces_origin_on_browser_auth_mutations() { + let workspace = tempfile::tempdir().unwrap(); + let api = test_api(workspace.path()).await; + seed_test_api_token(api.store.as_ref(), "direct-cookie"); + api.store + .create_browser_session(&BrowserSessionRecord { + token_hash: crate::auth::token_hash("direct-browser-session"), + session_id: "direct-session".to_owned(), + user_id: "user-direct-cookie".to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + expires_at: "2099-01-01T00:00:00Z".to_owned(), + revoked_at: None, + }) + .unwrap(); + let AuthConfig::Passkey { + origin, + cookie_name, + .. + } = &api.config.auth; + let origin = origin.clone(); + let cookie = format!("{cookie_name}=direct-browser-session"); + let app = build_router(api.clone()); + + let cross_site = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/device-login/approve") + .header(axum::http::header::COOKIE, &cookie) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(cross_site.status(), StatusCode::FORBIDDEN); + + let same_origin = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/device-login/approve") + .header(axum::http::header::COOKIE, cookie) + .header(ORIGIN, origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(same_origin.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn browser_session_set_and_clear_cookies_follow_public_https_scheme() { + for (scheme, secure) in [("http", false), ("https", true)] { + let workspace = tempfile::tempdir().unwrap(); + let mut api = test_api(workspace.path()).await; + let AuthConfig::Passkey { + origin, + public_base_url, + .. + } = &mut api.config.auth; + *origin = format!("{scheme}://workspace.test"); + *public_base_url = format!("{scheme}://workspace.test"); + seed_test_api_token(api.store.as_ref(), &format!("cookie-{scheme}")); + let user = api + .store + .get_user(&format!("user-cookie-{scheme}")) + .unwrap() + .unwrap(); + let auth_api = ServerAuthApi::from(&api); + + let login = issue_browser_session_response(&auth_api, user).unwrap(); + let login_cookie = login.headers().get(SET_COOKIE).unwrap().to_str().unwrap(); + assert_eq!(login_cookie.contains("; Secure"), secure, "{scheme}"); + assert!(login_cookie.contains("; HttpOnly; SameSite=Lax")); + + let logout = post_auth_logout(State(auth_api), HeaderMap::new()) + .await + .unwrap(); + let logout_cookie = logout.headers().get(SET_COOKIE).unwrap().to_str().unwrap(); + assert_eq!(logout_cookie.contains("; Secure"), secure, "{scheme}"); + assert!(logout_cookie.contains("Max-Age=0")); + assert!(logout_cookie.contains("; HttpOnly; SameSite=Lax")); + for cookie in [login_cookie, logout_cookie] { + assert!(cookie.contains("; Path=/")); + assert!(!cookie.contains("; Domain=")); + } + } + } + #[tokio::test] async fn server_router_dispatches_two_workspace_contexts_without_state_leakage() { let dir = tempfile::tempdir().unwrap(); @@ -15535,13 +16384,17 @@ mod tests { None, ) .unwrap(); + let token = seed_test_api_token(store.as_ref(), "two-workspaces"); let app = build_workspace_server_router(template, store) .await .unwrap(); let uri_a = format!("/api/w/{}/workspace", workspace_a.workspace.workspace_id); let uri_b = format!("/api/w/{}/workspace", workspace_b.workspace.workspace_id); - let (a, b) = tokio::join!(get_json(app.clone(), &uri_a), get_json(app.clone(), &uri_b)); + let (a, b) = tokio::join!( + get_json_authenticated(app.clone(), &uri_a, &token), + get_json_authenticated(app.clone(), &uri_b, &token) + ); assert_eq!(a["workspace_id"], workspace_a.workspace.workspace_id); assert_eq!(a["display_name"], "Workspace A"); assert_eq!(b["workspace_id"], workspace_b.workspace.workspace_id); @@ -15599,7 +16452,7 @@ mod tests { .clone() .oneshot( Request::post(format!( - "/internal/w/{}/runtime/resources/fetch", + "/api/runtime/v1/workspaces/{}/resources/fetch", workspace_b.workspace.workspace_id )) .header(axum::http::header::CONTENT_TYPE, "application/json") @@ -15616,19 +16469,13 @@ mod tests { ) .await .unwrap(); - assert_eq!(resource_response.status(), StatusCode::NOT_FOUND); - let resource_error: BackendResourceError = serde_json::from_slice( - &to_bytes(resource_response.into_body(), usize::MAX) - .await - .unwrap(), - ) - .unwrap(); - assert_eq!(resource_error, BackendResourceError::MissingResource); + assert_eq!(resource_response.status(), StatusCode::UNAUTHORIZED); let missing = app .oneshot( Request::builder() .uri("/api/w/00000000-0000-0000-0000-000000000001/workspace") + .header(axum::http::header::AUTHORIZATION, format!("Bearer {token}")) .body(Body::empty()) .unwrap(), ) @@ -15644,6 +16491,7 @@ mod tests { std::fs::create_dir_all(repository.join(".git")).unwrap(); let template = test_server_config(dir.path()).with_local_workspace_bootstrap(true); let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); + let token = seed_test_api_token(store.as_ref(), "bootstrap"); let app = build_workspace_server_router(template, store) .await .unwrap(); @@ -15674,7 +16522,12 @@ mod tests { let body: Value = serde_json::from_slice(&body).unwrap(); let workspace_id = body["workspace"]["workspace_id"].as_str().unwrap(); - let workspace = get_json(app.clone(), &format!("/api/w/{workspace_id}/workspace")).await; + let workspace = get_json_authenticated( + app.clone(), + &format!("/api/w/{workspace_id}/workspace"), + &token, + ) + .await; assert_eq!(workspace["display_name"], "Created Workspace"); let replayed = app @@ -16462,7 +17315,7 @@ mod tests { "x-yoi-worker-id", axum::http::HeaderValue::from_str(&source_worker.worker.worker_id).unwrap(), ); - let response = build_router(api.clone()) + let response = build_inner_router(api.clone()) .oneshot( Request::builder() .method("POST") @@ -17921,7 +18774,7 @@ mod tests { )); let temp = tempfile::tempdir().unwrap(); - let app = build_router(test_api(temp.path()).await); + let app = build_inner_router(test_api(temp.path()).await); let body = r#"{"target_runtime_id":"runtime-target","target_worker_id":"target-worker","reason":"retire target Worker"}"#; let browser = app .clone() @@ -18406,7 +19259,7 @@ mod tests { 60, ) .unwrap(); - let route_response = build_router(api.clone()) + let route_response = build_inner_router(api.clone()) .oneshot( Request::builder() .method("POST") @@ -18621,7 +19474,7 @@ mod tests { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; - let response = build_router(api) + let response = build_inner_router(api) .oneshot( Request::builder() .method("POST") @@ -18879,8 +19732,37 @@ mod tests { ); } + const TEST_RUNTIME_HTTP_TOKEN: &str = "workspace-server-test-runtime-token"; + + async fn serve_runtime_http_with_injected_test_auth( + runtime: worker_runtime::Runtime, + listener: tokio::net::TcpListener, + ) -> std::io::Result<()> { + const TOKEN: &str = "workspace-server-test-runtime-token"; + + async fn inject_runtime_auth( + State(inner): State, + mut request: Request, + ) -> Response { + request.headers_mut().insert( + axum::http::header::AUTHORIZATION, + axum::http::HeaderValue::from_static("Bearer workspace-server-test-runtime-token"), + ); + match inner.oneshot(request).await { + Ok(response) => response, + Err(error) => match error {}, + } + } + + let protected = worker_runtime::http_server::runtime_http_router(runtime, TOKEN.to_owned()); + let proxy = Router::new() + .fallback(axum::routing::any(inject_runtime_auth)) + .with_state(protected); + axum::serve(listener, proxy).await + } + async fn test_app(workspace_root: impl Into) -> Router { - build_router(test_api(workspace_root).await) + build_inner_router(test_api(workspace_root).await) } fn test_profile_archive() -> worker_runtime::profile_archive::ProfileSourceArchive { @@ -18938,29 +19820,64 @@ mod tests { } } + #[tokio::test] + async fn runtime_request_proof_rejects_trust_bound_to_another_workspace() { + let workspace = tempfile::tempdir().unwrap(); + let mut api = test_api(workspace.path()).await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-test"); + let other_workspace = "019d0000-0000-7000-8000-0000000000bb"; + let path = format!("/api/runtime/v1/workspaces/{other_workspace}/resources/fetch"); + let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity) + .issue( + "server-test", + other_workspace, + None, + worker_runtime::auth::BACKEND_RESOURCE_FETCH_PERMISSION, + "POST", + &path, + b"{}", + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap_or(i64::MAX), + 30, + ) + .unwrap(); + let result = crate::worker_source::verify_runtime_request_source_proof_with_store( + api.store.as_ref(), + &api.config, + &proof, + other_workspace, + worker_runtime::auth::BACKEND_RESOURCE_FETCH_PERMISSION, + "POST", + &path, + &worker_runtime::auth::request_body_digest(b"{}"), + ) + .await; + assert!(matches!( + result, + Err(crate::worker_source::WorkerMutationSourceProofError::WrongWorkspace) + )); + } + #[tokio::test] async fn internal_resource_fetch_rest_returns_typed_missing_resource() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); - let app = test_app(workspace.path()).await; + let mut api = test_api(workspace.path()).await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-test"); + let app = build_inner_router(api.clone()); let handle = missing_resource_handle(); + let body = serde_json::to_vec(&worker_runtime::resource::BackendResourceFetchRequest { + audit_correlation_id: handle.audit_correlation_id.clone(), + runtime_id: "runtime-test".to_string(), + worker_id: None, + handle, + }) + .unwrap(); let response = app - .oneshot( - Request::post(format!( - "/internal/w/{TEST_WORKSPACE_ID}/runtime/resources/fetch" - )) - .header("content-type", "application/json") - .body(Body::from( - serde_json::to_vec(&worker_runtime::resource::BackendResourceFetchRequest { - audit_correlation_id: handle.audit_correlation_id.clone(), - runtime_id: "runtime-test".to_string(), - worker_id: None, - handle, - }) - .unwrap(), - )) - .unwrap(), - ) + .oneshot(runtime_resource_fetch_request(&api, &identity, body)) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); @@ -18973,11 +19890,70 @@ mod tests { )); } + #[tokio::test] + async fn runtime_signed_profile_source_archive_fetch_uses_resource_permission() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let mut api = test_api(workspace.path()).await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-test"); + let handle = api.resource_broker.issue_profile_source_archive_handle( + TEST_WORKSPACE_ID, + crate::resource_broker::BackendResourceTarget::Runtime("runtime-test"), + test_profile_archive(), + ); + let path = format!( + "/api/w/{TEST_WORKSPACE_ID}/profile-source-archives/{}", + handle.digest + ); + let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity) + .issue( + "server-test", + TEST_WORKSPACE_ID, + None, + worker_runtime::auth::BACKEND_RESOURCE_FETCH_PERMISSION, + "GET", + &path, + b"", + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap_or(i64::MAX), + 30, + ) + .unwrap(); + let response = build_router(api) + .oneshot( + Request::builder() + .uri(path) + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(ETAG).unwrap().to_str().unwrap(), + format!("\"profile-source:{}\"", handle.digest) + ); + assert!( + !to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .is_empty() + ); + } + #[tokio::test] async fn remote_http_resource_fetch_uses_backend_resource_contract() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); - let api = test_api(workspace.path()).await; + let mut api = test_api(workspace.path()).await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-test"); let broker = api.resource_broker.clone(); let archive = test_profile_archive(); let runtime_id = "runtime-test"; @@ -18986,14 +19962,15 @@ mod tests { crate::resource_broker::BackendResourceTarget::Runtime(runtime_id), archive, ); - let app = build_router(api); + let app = build_inner_router(api); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let client = worker_runtime::resource::HttpBackendResourceClient::new( - format!("http://{addr}/internal/w/{TEST_WORKSPACE_ID}/runtime/resources/fetch"), + format!("http://{addr}/api/runtime/v1/workspaces/{TEST_WORKSPACE_ID}/resources/fetch"), None, - ); + ) + .with_runtime_request_source(&identity, "server-test"); let response = client .fetch_resource(worker_runtime::resource::BackendResourceFetchRequest { @@ -19053,6 +20030,8 @@ mod tests { fn runtime_create_request() -> worker_runtime::catalog::CreateWorkerRequest { let bundle = runtime_test_bundle(); + let mut memory_settings = test_worker_memory_settings(); + memory_settings.workspace_id = "local".to_owned(); worker_runtime::catalog::CreateWorkerRequest { worker_id: WorkerId::now_v7(), create_fingerprint: "test-create".to_string(), @@ -19087,7 +20066,7 @@ mod tests { worker_observation_enabled: false, worker_observation_grants: Vec::new(), workspace_api: None, - memory_settings: None, + memory_settings: Some(memory_settings), } } @@ -19098,7 +20077,12 @@ mod tests { ) .unwrap(); runtime.store_config_bundle(runtime_test_bundle()).unwrap(); - let worker = runtime.create_worker(runtime_create_request()).unwrap(); + let worker = runtime + .create_worker_scoped( + &worker_runtime::RuntimeWorkspaceScope::new("local", "local-token"), + runtime_create_request(), + ) + .unwrap(); (runtime, worker.worker_ref) } @@ -19469,7 +20453,7 @@ mod tests { tokio::spawn({ let runtime = runtime.clone(); async move { - worker_runtime::http_server::serve_runtime_http(runtime, runtime_listener, None) + serve_runtime_http_with_injected_test_auth(runtime, runtime_listener) .await .unwrap() } @@ -19535,7 +20519,7 @@ mod tests { tokio::spawn({ let runtime = runtime.clone(); async move { - worker_runtime::http_server::serve_runtime_http(runtime, runtime_listener, None) + serve_runtime_http_with_injected_test_auth(runtime, runtime_listener) .await .unwrap() } @@ -19600,7 +20584,7 @@ mod tests { let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let runtime_addr = runtime_listener.local_addr().unwrap(); tokio::spawn(async move { - worker_runtime::http_server::serve_runtime_http(runtime, runtime_listener, None) + serve_runtime_http_with_injected_test_auth(runtime, runtime_listener) .await .unwrap() }); @@ -19649,7 +20633,7 @@ mod tests { #[tokio::test] async fn merge_request_reads_use_first_class_workspace_resources() { let dir = tempfile::tempdir().unwrap(); - let app = build_router(test_api(dir.path()).await); + let app = build_inner_router(test_api(dir.path()).await); let collection = app .clone() @@ -19712,7 +20696,7 @@ mod tests { { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; - let app = build_router(api); + let app = build_inner_router(api); let response = app .clone() @@ -20128,7 +21112,7 @@ mod tests { ) .await .unwrap(); - let app = build_router(api); + let app = build_inner_router(api); let workspace = get_json(app.clone(), "/api/workspace").await; assert_eq!(workspace["workspace_id"], TEST_WORKSPACE_ID); @@ -20530,7 +21514,7 @@ mod tests { ) .await .unwrap(); - let app = build_router(api); + let app = build_inner_router(api); let workspace = get_json(app.clone(), "/api/workspace").await; let workspace_companion = &workspace["extension_points"]["companion_console"]; @@ -20719,7 +21703,7 @@ mod tests { .with_embedded_runtime_store_root(default_root.clone()); config.database_path = ServerConfig::server_database_path_for_data_dir(&data_dir); let store = test_control_store(&config); - let app = build_router( + let app = build_inner_router( WorkspaceApi::new_with_execution_backend( config, Arc::new(store), @@ -20757,7 +21741,7 @@ mod tests { ) .await .unwrap(); - let app = build_router(api); + let app = build_inner_router(api); let repositories = get_json(app, "/api/repositories").await; @@ -20795,7 +21779,7 @@ mod tests { ) .await .unwrap(); - let app = build_router(api); + let app = build_inner_router(api); let unknown = request_json( app.clone(), @@ -20836,7 +21820,7 @@ mod tests { ) .await .unwrap(); - let app = build_router(api); + let app = build_inner_router(api); let runtimes = get_json(app.clone(), "/api/runtimes").await; let embedded_summary = runtimes["items"] @@ -20995,7 +21979,7 @@ mod tests { let source = RuntimeObservationSourceConfig { worker: RuntimeWorkerRef::new("runtime-a", "worker-a"), endpoint, - bearer_token: None, + bearer_token: Some(TEST_RUNTIME_HTTP_TOKEN.to_owned()), }; let (url, _dir) = spawn_workspace_proxy(source).await; let (mut stream, _) = connect_async(&url).await.unwrap(); @@ -21041,7 +22025,7 @@ mod tests { let source = RuntimeObservationSourceConfig { worker: RuntimeWorkerRef::new("runtime-a", "worker-a"), endpoint, - bearer_token: None, + bearer_token: Some(TEST_RUNTIME_HTTP_TOKEN.to_owned()), }; let (url, _dir) = spawn_workspace_proxy(source).await; let (mut stream, _) = connect_async(&url).await.unwrap(); @@ -21074,9 +22058,13 @@ mod tests { tokio::spawn({ let runtime = runtime.clone(); async move { - worker_runtime::http_server::serve_runtime_http(runtime, runtime_listener, None) - .await - .unwrap() + worker_runtime::http_server::serve_runtime_http( + runtime, + runtime_listener, + Some(TEST_RUNTIME_HTTP_TOKEN.to_owned()), + ) + .await + .unwrap() } }); let endpoint = format!( @@ -21104,7 +22092,11 @@ mod tests { .unwrap(); let app_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let app_addr = app_listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(app_listener, build_router(api)).await.unwrap() }); + tokio::spawn(async move { + axum::serve(app_listener, build_inner_router(api)) + .await + .unwrap() + }); ( format!("ws://{app_addr}/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws"), dir, @@ -21112,11 +22104,11 @@ mod tests { } #[tokio::test] - async fn workspace_subscription_uses_legacy_scoped_access_without_browser_session() { + async fn workspace_subscription_inner_router_projects_snapshot() { let dir = tempfile::tempdir().unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); - let app = build_router(test_api(dir.path()).await); + let app = build_inner_router(test_api(dir.path()).await); let server = tokio::spawn(async move { let _ = axum::serve(listener, app).await; }); @@ -21164,7 +22156,7 @@ mod tests { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); - let app = build_router(api); + let app = build_inner_router(api); let server = tokio::spawn(async move { let _ = axum::serve(listener, app).await; }); @@ -21561,7 +22553,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); ) .await .unwrap(); - let app = build_router(api); + let app = build_inner_router(api); let objectives_path = format!("/api/w/{TEST_WORKSPACE_ID}/objectives"); let created = request_json( @@ -21703,6 +22695,22 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); ); } + async fn get_json_authenticated(app: Router, uri: &str, token: &str) -> Value { + let response = app + .oneshot( + Request::builder() + .uri(uri) + .header(axum::http::header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{uri}"); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + async fn get_json(app: Router, uri: &str) -> Value { let response = app .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 496bb61f..4ff44b16 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -761,6 +761,7 @@ pub trait ControlPlaneStore: Send + Sync { record: &WorkspaceBootstrapRecord, ) -> Result; async fn get_trusted_runtime(&self, runtime_id: &str) -> Result>; + async fn upsert_trusted_runtime_record(&self, record: &TrustedRuntimeRecord) -> Result<()>; async fn consume_worker_mutation_source_jti( &self, runtime_id: &str, @@ -1975,6 +1976,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + async fn upsert_trusted_runtime_record(&self, record: &TrustedRuntimeRecord) -> Result<()> { + SqliteWorkspaceStore::upsert_trusted_runtime(self, record) + } + async fn consume_worker_mutation_source_jti( &self, runtime_id: &str, diff --git a/crates/workspace-server/src/worker_source.rs b/crates/workspace-server/src/worker_source.rs index 08504cca..2affde46 100644 --- a/crates/workspace-server/src/worker_source.rs +++ b/crates/workspace-server/src/worker_source.rs @@ -3,14 +3,118 @@ use std::time::{SystemTime, UNIX_EPOCH}; use axum::http::HeaderMap; use worker_runtime::auth::{ - WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims, - WorkerMutationSourceExpectation, decode_worker_mutation_source_claims, - verify_worker_mutation_source_proof, + RuntimeRequestSourceExpectation, WorkerMutationActorKind, WorkerMutationOperation, + WorkerMutationSourceClaims, WorkerMutationSourceExpectation, + decode_runtime_request_source_claims, decode_worker_mutation_source_claims, + verify_runtime_request_source, verify_worker_mutation_source_proof, }; use worker_runtime::worker_source::InProcessWorkerMutationProof; use crate::hosts::RemoteRuntimeConfig; -use crate::server::WorkspaceApi; +use crate::server::{ServerConfig, WorkspaceApi}; +use crate::store::ControlPlaneStore; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedRuntimeRequestSource { + pub runtime_id: String, + pub worker_id: Option, +} + +pub async fn verify_runtime_request_source_proof( + api: &WorkspaceApi, + proof: &str, + workspace_id: &str, + permission: &str, + method: &str, + path: &str, + body_digest: &str, +) -> Result { + verify_runtime_request_source_proof_with_store( + api.store.as_ref(), + &api.config, + proof, + workspace_id, + permission, + method, + path, + body_digest, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn verify_runtime_request_source_proof_with_store( + store: &dyn ControlPlaneStore, + config: &ServerConfig, + proof: &str, + workspace_id: &str, + permission: &str, + method: &str, + path: &str, + body_digest: &str, +) -> Result { + let unverified = decode_runtime_request_source_claims(proof) + .map_err(|_| WorkerMutationSourceProofError::Invalid)?; + let audience = remote_audience(config, &unverified.iss, workspace_id)?; + let trusted = store + .get_trusted_runtime(&unverified.iss) + .await + .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? + .filter(|record| record.revoked_at.is_none()) + .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; + let trusted_for_workspace = trusted.workspace_id.as_deref() == Some(workspace_id) + || (unverified.iss == crate::hosts::EMBEDDED_RUNTIME_ID && trusted.workspace_id.is_none()); + if !trusted_for_workspace { + return Err(WorkerMutationSourceProofError::WrongWorkspace); + } + let expected = RuntimeRequestSourceExpectation { + identity_id: &unverified.iss, + audience: audience.as_ref(), + workspace_id, + worker_id: unverified.worker_id.as_deref(), + permission, + method, + path, + body_digest, + now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), + }; + let claims = verify_runtime_request_source(proof, &trusted.public_key, &expected) + .map_err(map_auth_error)?; + let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX); + let expires_at = u64::try_from(claims.exp).unwrap_or(0); + let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0) + .ok_or(WorkerMutationSourceProofError::Expired)? + .to_rfc3339(); + if !store + .consume_worker_mutation_source_jti( + &claims.iss, + &claims.jti, + expires_at, + now_seconds, + &consumed_at, + ) + .await + .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? + { + return Err(WorkerMutationSourceProofError::Replay); + } + if let Some(worker_id) = claims.worker_id.as_deref() { + let worker = worker_runtime::identity::RuntimeWorkerRef { + runtime_id: claims.iss.clone(), + worker_id: worker_id.to_owned(), + }; + let member = store + .get_worker_registry(workspace_id, &worker) + .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; + if member.is_none() { + return Err(WorkerMutationSourceProofError::WorkerCatalogMembership); + } + } + Ok(VerifiedRuntimeRequestSource { + runtime_id: claims.iss, + worker_id: claims.worker_id, + }) +} #[derive(Clone, Debug, PartialEq, Eq)] pub enum PresentedWorkerMutationSourceProof<'a> { @@ -97,16 +201,19 @@ async fn verify_worker_remove_source_with( PresentedWorkerMutationSourceProof::Remote(token) => { let unverified = decode_worker_mutation_source_claims(token) .map_err(|_| WorkerMutationSourceProofError::Invalid)?; - let audience = remote_audience(config, &unverified.iss)?; + let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?; let trusted = store .get_trusted_runtime(&unverified.iss) .await .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? .filter(|record| record.revoked_at.is_none()) .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; + if trusted.workspace_id.as_deref() != Some(config.workspace_id.as_str()) { + return Err(WorkerMutationSourceProofError::WrongWorkspace); + } let expected = WorkerMutationSourceExpectation { runtime_id: &unverified.iss, - audience, + audience: audience.as_ref(), workspace_id: &config.workspace_id, worker_id: None, actor_kind: WorkerMutationActorKind::Worker, @@ -247,13 +354,17 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher fn remote_audience<'a>( config: &'a crate::server::ServerConfig, runtime_id: &str, -) -> Result<&'a str, WorkerMutationSourceProofError> { + workspace_id: &str, +) -> Result, WorkerMutationSourceProofError> { + if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID { + return Ok(std::borrow::Cow::Owned(format!("embedded:{workspace_id}"))); + } config .remote_runtime_sources .iter() .find(|runtime| runtime.runtime_id == runtime_id) .and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref()) - .map(|auth| auth.server_id.as_str()) + .map(|auth| std::borrow::Cow::Borrowed(auth.server_id.as_str())) .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust) }