From 9005856f774cefe2b787c2991b0021e235342218 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 07:24:51 +0900 Subject: [PATCH] fix: replay accepted delegation without reserving active Workdirs --- crates/server-api/src/lib.rs | 8 +- crates/workspace-server/src/server.rs | 32 + .../tests/subject_authorization_tests.rs | 624 +++++++++++++++++- openapi/server-api.json | 8 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 10 files changed, 669 insertions(+), 15 deletions(-) diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 9110e0e62..1de9be8e8 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -1351,6 +1351,7 @@ pub trait ServerApi { status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, browser_auth = true, )] async fn subjektiv_subject_permissions_get( @@ -1365,6 +1366,7 @@ pub trait ServerApi { status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, browser_auth = true, )] async fn subjektiv_subject_permissions_set( @@ -14697,11 +14699,7 @@ mod openapi_artifact_tests { // the Runtime/Worker-source signed operation inventory above. const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session", "worker_session_history"]; const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"]; - const BROWSER_ONLY: &[&str] = &[ - "merge_request_selector_repair", - "subjektiv_subject_permissions_get", - "subjektiv_subject_permissions_set", - ]; + const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"]; let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); let value: serde_json::Value = diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 4bc5d6297..fd0f7d6ec 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5708,6 +5708,38 @@ impl WorkspaceApi { if let Some(grant) = request.resolved_execution_grant.as_ref() { subject_authorization::bind_reserved_grant(self, grant, &worker_id.to_string())?; } + // A completed exact-source allocation is historical acceptance, not a new + // launch. Source/grant authorization and the full fingerprint were checked + // above. Do not reserve its already-linked attachments again, redispatch + // initial input, or compensate an existing Worker on a replay failure. + let reserved_worker = RuntimeWorkerRef::new(runtime_id, worker_id.to_string()); + if request.resolved_control_operation.is_some() + && !self + .store + .has_active_worker_create_reservation(&self.config.workspace_id, &reserved_worker)? + { + let record = self + .store + .get_worker_registry(&self.config.workspace_id, &reserved_worker)? + .ok_or_else(|| { + Error::Store("completed Worker allocation has no registry evidence".into()) + })?; + let summary = self + .runtime + .worker(&reserved_worker) + .unwrap_or_else(|_| worker_summary_from_registry(&record)); + return Ok(WorkerSpawnResult { + state: InternalWorkerOperationState::Accepted, + worker: Some(summary), + acceptance_evidence: vec![crate::hosts::WorkerSpawnAcceptanceEvidence { + kind: "saved_creation".into(), + detail: + "Exact allocation was previously accepted; no new execution was requested" + .into(), + }], + diagnostics: Vec::new(), + }); + } if let Some(assignment) = request.ticket_assignment.as_ref() { self.store.bind_ticket_assignment_operation_worker( &self.config.workspace_id, diff --git a/crates/workspace-server/src/server/tests/subject_authorization_tests.rs b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs index 067b02f8e..0cbfa5194 100644 --- a/crates/workspace-server/src/server/tests/subject_authorization_tests.rs +++ b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs @@ -1,8 +1,9 @@ //! Normal Server library boundary tests (`subject_authorization_tests` filter). //! Router authentication, Host creation/reservation, Subject policy storage, Worker -//! Feature installation and Ticket SQLite writes are real. Only Runtime execution -//! is replaced by WorkdirlessFixtureRuntime; the unused model never runs. These -//! are not product E2E tests and do not claim OS isolation or child tool execution. +//! Feature installation and Ticket SQLite writes are real. The original boundary +//! cases substitute WorkdirlessFixtureRuntime. The connected regression below also +//! runs a real ProfileRuntimeWorkerFactory/Controller and materialized Workdir; +//! only its external model stream is scripted. Neither fixture claims OS isolation. use super::*; use agen::tool::{Tool, ToolExecutionContext, ToolOutput}; use server_api::authorization::{ @@ -1528,3 +1529,620 @@ async fn signed_host_scope_guards_real_local_read_write_and_revocation_before_ef ); session.close().await.unwrap(); } + +/// Script only the external model boundary. Tool results come back through the +/// production Controller; this model never reads or writes the fixture itself. +#[derive(Clone)] +struct ConnectedAuthorizationModel { + completed: tokio::sync::mpsc::Sender>, +} + +#[async_trait::async_trait] +impl agen::llm_client::LlmClient for ConnectedAuthorizationModel { + async fn stream( + &self, + request: agen::llm_client::Request, + ) -> std::result::Result< + std::pin::Pin< + Box< + dyn futures::Stream< + Item = std::result::Result< + agen::llm_client::event::Event, + agen::llm_client::error::ClientError, + >, + > + Send, + >, + >, + agen::llm_client::error::ClientError, + > { + use agen::llm_client::{ + event::{Event, StopReason}, + types::Item, + }; + let results: Vec<_> = request + .items + .iter() + .filter_map(|item| match item { + Item::ToolResult { + call_id, + is_error, + content, + summary, + .. + } if matches!(call_id.as_str(), "connected-read" | "connected-write") => Some(( + call_id.clone(), + *is_error, + format!("{summary}; {content:?}"), + )), + _ => None, + }) + .collect(); + let events = if results.iter().any(|(id, _, _)| id == "connected-write") { + // A bounded channel is our completion barrier: no timers, sleeps or + // filesystem polling decide whether the real tools have executed. + let _ = self.completed.try_send(results); + vec![ + Event::text_block_start(0), + Event::text_delta(0, "connected tools finished"), + Event::text_block_stop(0, Some(StopReason::EndTurn)), + ] + } else { + let (id, name, input) = if results.iter().any(|(id, _, _)| id == "connected-read") { + ( + "connected-write", + "Write", + json!({"target_workdir":"workdir", "file_path":"README.md", "content":"MUST_NOT_REPLACE_README\n"}), + ) + } else { + ( + "connected-read", + "Read", + json!({"target_workdir":"workdir", "file_path":"README.md"}), + ) + }; + assert!( + request.tools.iter().any(|tool| tool.name == name), + "editing Profile must expose {name}; denial must not be schema absence" + ); + vec![ + Event::tool_use_start(0, id, name), + Event::tool_input_delta(0, input.to_string()), + Event::tool_use_stop(0), + ] + }; + Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) + } + + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Use the same signed source protocol as RuntimeOwnedWorkspaceClient, with a +/// per-test audience and listener rather than the older server-test/8787 fixture. +async fn connected_authorization_post( + base_url: &str, + identity: &RuntimeIdentityMaterial, + source: &RuntimeWorkerRef, + path: &str, + body: &Value, +) -> (StatusCode, Value) { + let bytes = serde_json::to_vec(body).unwrap(); + let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(identity) + .issue( + base_url, + TEST_WORKSPACE_ID, + Some(&source.worker_id), + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION, + "POST", + path, + &bytes, + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(), + 30, + ) + .unwrap(); + let response = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap() + .post(format!("{base_url}{path}")) + .header(CONTENT_TYPE, "application/json") + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ) + .body(bytes) + .send() + .await + .unwrap(); + let status = StatusCode::from_u16(response.status().as_u16()).unwrap(); + let bytes = response.bytes().await.unwrap(); + let body = serde_json::from_slice(&bytes) + .unwrap_or_else(|_| json!({"text":String::from_utf8_lossy(&bytes)})); + (status, body) +} + +// The Runtime HTTP boundary owns synchronous lifecycle handlers. Give it an +// independent scheduler, matching separate product processes: a blocked Runtime +// request must not pin the Backend callback's Tokio local scheduling queue. +struct ConnectedRuntimeServer { + shutdown: Option>, + thread: Option>, +} +impl ConnectedRuntimeServer { + fn start(listener: tokio::net::TcpListener, router: Router) -> Self { + let listener = listener.into_std().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let thread = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + .unwrap(); + }); + runtime.shutdown_timeout(std::time::Duration::from_secs(5)); + }); + Self { + shutdown: Some(shutdown), + thread: Some(thread), + } + } +} +impl Drop for ConnectedRuntimeServer { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 6)] +async fn connected_read_only_subject_delegates_editing_profile_to_real_runtime_read_but_not_write() +{ + use worker_runtime::working_directory::WorkingDirectoryMaterializer; + + const RUNTIME: &str = "connected-subject-authorization"; + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let backend_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let backend_url = format!("http://{}", backend_listener.local_addr().unwrap()); + let runtime_url = format!("http://{}", runtime_listener.local_addr().unwrap()); + let identity = RuntimeIdentityMaterial::generate(RUNTIME).unwrap(); + let runtime_root = dir.path().join("connected-runtime"); + let materializer = worker_runtime::working_directory::RuntimeGitMaterializer::new( + dir.path().join("connected-workdirs"), + ); + let (completed, mut completion) = tokio::sync::mpsc::channel(4); + let model = ConnectedAuthorizationModel { completed }; + let factory = worker_runtime::worker_backend::ProfileRuntimeWorkerFactory::new( + dir.path().join("connected-profiles"), + ) + .with_remote_worker_mutation_identity(identity.clone()) + .with_workspace_request_client( + worker_runtime::workspace_request::RuntimeWorkspaceRequestClient::new( + TEST_WORKSPACE_ID, + &backend_url, + RUNTIME, + ) + .with_runtime_request_source(&identity, &backend_url), + ) + .with_runtime_store_dir(&runtime_root) + .with_spawn_model_client_factory(move || Box::new(model.clone())); + let execution = worker_runtime::worker_backend::WorkerRuntimeExecutionBackend::new(factory) + .unwrap() + .with_working_directory_materializer(materializer.clone()); + let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend( + worker_runtime::fs_store::FsRuntimeStoreOptions { + root: runtime_root, + runtime_id: RUNTIME.into(), + display_name: None, + }, + Arc::new(execution), + ) + .unwrap(); + + let mut config = test_server_config(dir.path()); + config.backend_base_url = Some(backend_url.clone()); + // The embedded Runtime is unused. Both source and child below execute on the + // connected real Runtime above, not on this existing Host fixture backend. + let control = Arc::new(test_control_store(&config)); + seed_test_registered_workspace(control.as_ref(), &config) + .await + .unwrap(); + let api = WorkspaceApi::new_with_execution_backend( + config, + control, + Arc::new(DeterministicExecutionBackend::default()), + ) + .await + .unwrap(); + let now = now_registry_timestamp(); + api.store + .upsert_workspace_runtime_binding_record( + WorkspaceRuntimeBinding { + workspace_id: TEST_WORKSPACE_ID.into(), + runtime_id: RUNTIME.into(), + display_name: RUNTIME.into(), + base_url: runtime_url.clone(), + public_key: identity.public_key.clone(), + public_key_fingerprint: String::new(), + binding_id: "connected-authorization-binding".into(), + authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, + created_at: now.clone(), + updated_at: now, + revoked_at: None, + }, + false, + ) + .await + .unwrap(); + let binding = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, RUNTIME) + .await + .unwrap() + .unwrap(); + // Repository materialization and Worker startup must authenticate as this + // Workspace, not the Runtime HTTP local-token fallback's `local` Workspace. + let workspace_identity = api + .signing_identities + .provision_existing(TEST_WORKSPACE_ID, &test_owner_actor().account_id) + .unwrap(); + let verifier = worker_runtime::workspace_issuer::WorkspaceCapabilityVerifier::new( + vec![ + worker_runtime::workspace_issuer::WorkspaceIssuerTrustRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + backend_url: backend_url.clone(), + key_id: workspace_identity.key_id.clone(), + algorithm: workspace_identity.algorithm.clone(), + public_key: workspace_identity.public_key.clone().unwrap(), + public_key_fingerprint: workspace_identity.public_key_fingerprint.clone().unwrap(), + trust_id: "connected-authorization-workspace-trust".into(), + state: worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Active, + registered_at_unix: 1, + updated_at_unix: 1, + }, + ], + Arc::new( + worker_runtime::workspace_issuer::InMemoryWorkspaceClaimReplayProtection::default(), + ), + ) + .unwrap(); + let runtime_errors = Arc::new(Mutex::new(Vec::::new())); + let captured_errors = runtime_errors.clone(); + let _runtime_server = ConnectedRuntimeServer::start( + runtime_listener, + worker_runtime::http_server::runtime_http_router_with_workspace_auth( + runtime.clone(), + None, + worker_runtime::http_server::WorkspaceRuntimeHttpAuth { + verifier, + runtime_id: RUNTIME.into(), + }, + ) + .layer(axum::middleware::from_fn( + move |request: axum::extract::Request, next: axum::middleware::Next| { + let errors = captured_errors.clone(); + async move { + let response = next.run(request).await; + if response.status().is_success() { + return response; + } + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + errors.lock().unwrap().push( + body["error"]["message"] + .as_str() + .unwrap_or("unknown fixture error") + .to_owned(), + ); + Response::from_parts(parts, Body::from(bytes)) + } + }, + )), + ); + api.runtime.register_or_replace( + RemoteWorkerRuntime::new( + RemoteRuntimeConfig { + runtime_id: RUNTIME.into(), + workspace_id: Some(TEST_WORKSPACE_ID.into()), + display_name: RUNTIME.into(), + base_url: runtime_url, + bearer_token: None, + workspace_authorization: Some(crate::hosts::WorkspaceRuntimeAuthorization::new( + api.store.clone(), + api.signing_identities.clone(), + backend_url.clone(), + binding, + )), + strict_public_egress: false, + cached_worker_creation_available: true, + cached_os: "linux".into(), + cached_arch: "x86_64".into(), + cached_status: "active".into(), + timeout: std::time::Duration::from_secs(30), + }, + TEST_WORKSPACE_ID.to_string(), + backend_url.clone(), + ) + .unwrap(), + ); + install_subject_profiles(&api, "workspace_write"); + seed_test_delivery_binding(&api); + + let authorizations = Arc::new(Mutex::new(Vec::<(Value, StatusCode)>::new())); + let observed = authorizations.clone(); + let app = build_router(api.clone()).layer(axum::middleware::from_fn( + move |request: axum::extract::Request, next: axum::middleware::Next| { + let observed = observed.clone(); + async move { + if request.uri().path() != authorization_path() { + return next.run(request).await; + } + let (parts, body) = request.into_parts(); + let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap(); + let permission: Value = serde_json::from_slice(&bytes).unwrap(); + let response = next + .run(Request::from_parts(parts, Body::from(bytes))) + .await; + observed + .lock() + .unwrap() + .push((permission, response.status())); + response + } + }, + )); + let _backend_server = AuthorizationCallbackServer::start(backend_listener, app); + let owner = + seed_test_workspace_owner_token(api.store.as_ref(), "connected-authorization-owner").await; + let (status, created_workdir) = response(&api, human_request(&owner, "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"), + Some(json!({"runtime_id":RUNTIME, "repository_key":"test-repository", "selector":"HEAD", "operation_id":"connected-authorization-workdir"})))).await; + assert_eq!(status, StatusCode::CREATED, "{created_workdir}"); + let workdir_id = created_workdir["item"]["working_directory_id"] + .as_str() + .unwrap() + .to_owned(); + let binding = materializer + .bind_working_directory(&workdir_id, None) + .unwrap(); + let readme = binding.root().join("README.md"); + assert_eq!(fs::read_to_string(&readme).unwrap(), "clean\n"); + + let subject_store = open_subjektiv_store(&api).unwrap(); + let subject = subject_store + .create_subject(crate::subjektiv::SubjectRole::new("connected-authorization").unwrap()) + .unwrap() + .id; + let read_permission = ScopedPermission { + operation: "filesystem.read".into(), + resource: PermissionResource::Workdir { + working_directory_id: workdir_id.clone(), + path: ".".into(), + recursive: true, + }, + }; + let mut child_permissions: Vec<_> = [ + "task", + "web", + "image", + "sub_worker", + "flow", + "worker", + "workspace_worker_discovery", + "objective", + "manage_workdir", + "workdir_catalog", + "drive", + "ticket", + "merge_request", + "subjektiv", + ] + .into_iter() + .map(activation) + .collect(); + child_permissions.push(read_permission.clone()); + // Set policy before startup: the actual source Controller sees this policy, + // not an unset body or a registry/singleton row manufactured by the test. + let (status, body) = response(&api, human_request(&owner, "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"), + Some(json!({"operation_id":"connected-authorization-policy", "expected_change_id":null, + "execution":(["task", "subjektiv", "web", "image", "sub_worker", "worker", "workspace_worker_discovery", "objective", "manage_workdir", "workdir_catalog", "drive", "ticket"].into_iter().map(activation).collect::>()), + "delegation":child_permissions.clone()})))).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let Json(source) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: RUNTIME.into(), + display_name: "Connected read-only Subject body".into(), + singleton_key: None, + profile: Some("project:authority-source".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest { + subjektiv: Some(server_api::SubjektivWorkerConnectionRequest { + subject_id: subject.clone(), + }), + }, + control_operation_id: None, + execution_grant: None, + }), + ) + .await + .unwrap_or_else(|error| { + panic!( + "source creation: {error:?}; Runtime: {:?}", + runtime_errors.lock().unwrap() + ) + }); + let source = RuntimeWorkerRef::new(source.runtime_id, source.worker_id); + assert!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &source) + .unwrap() + .is_empty() + ); + assert!( + api.store + .require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &source) + .unwrap() + .is_some() + ); + let (status, body) = connected_authorization_post( + &backend_url, + &identity, + &source, + &authorization_path(), + &serde_json::to_value(&read_permission).unwrap(), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "body cannot execute its delegation ceiling: {body}" + ); + + // The normal noBash companion has no direct WorkerSpawn tool. Exercise its + // signed source CreateWorkspaceWorkerRequest transport explicitly, as opposed + // to installing a test-only privileged Feature or forging a child grant row. + let request = CreateWorkspaceWorkerRequest { + runtime_id: RUNTIME.into(), + display_name: "Connected ordinary editing Profile child".into(), + singleton_key: None, + profile: Some("project:authority-editor".into()), + ticket_assignment: None, + initial_submit: vec![protocol::Segment::Text { + content: "Read README.md, then attempt the denied Write.".into(), + }], + workdir_attachments: vec![server_api::BrowserWorkerWorkingDirectorySelection { + alias: "workdir".into(), + working_directory_id: workdir_id.clone(), + relative_cwd: None, + }], + feature_connections: Default::default(), + control_operation_id: Some("connected-authorization-child-once".into()), + execution_grant: Some(server_api::authorization::WorkerDelegationRequest { + permissions: child_permissions.clone(), + redelegation: vec![], + }), + }; + let path = format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"); + authorizations.lock().unwrap().clear(); + let wire = serde_json::to_value(request).unwrap(); + let (status, child) = + connected_authorization_post(&backend_url, &identity, &source, &path, &wire).await; + assert_eq!( + status, + StatusCode::OK, + "normal signed source creation: {child}; Runtime: {:?}", + runtime_errors.lock().unwrap() + ); + let child_ref = RuntimeWorkerRef::new( + child["runtime_id"].as_str().unwrap(), + child["worker_id"].as_str().unwrap(), + ); + let grant = subject_store + .worker_execution_grant(&child_ref.runtime_id, &child_ref.worker_id) + .unwrap() + .unwrap(); + assert_eq!(grant.origin_worker_id, source.worker_id); + assert_eq!(grant.permissions, child_permissions); + assert!(grant.redelegation.is_empty()); + assert!( + api.store + .require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &child_ref) + .unwrap() + .is_none(), + "ordinary Profile child must not become a Subject body" + ); + let (status, replay) = + connected_authorization_post(&backend_url, &identity, &source, &path, &wire).await; + assert_eq!(status, StatusCode::OK, "stable operation replay: {replay}"); + assert_eq!(replay["worker_id"], child["worker_id"]); + + let results = tokio::time::timeout(std::time::Duration::from_secs(30), completion.recv()) + .await + .expect("real child Controller must return both tool results") + .expect("scripted model completion channel"); + let read = results + .iter() + .find(|(id, _, _)| id == "connected-read") + .unwrap(); + assert!( + !read.1, + "actual Read must succeed under the read-only grant: {read:?}" + ); + assert!( + read.2.contains("clean"), + "Read must return materialized repository content: {read:?}" + ); + let write = results + .iter() + .find(|(id, _, _)| id == "connected-write") + .unwrap(); + assert!( + write.1, + "editing Profile must not enlarge the read-only grant: {write:?}" + ); + assert!( + write.2.contains("Workdir session does not support Write"), + "the Host-attenuated attachment must deny before provider dispatch: {write:?}" + ); + // The actual Write is refused even before provider dispatch by the narrowed + // attachment capabilities. Independently query the same child's live Host + // policy, rather than claiming that a refused Tool reached the write adapter. + let mut denied_write = read_permission.clone(); + denied_write.operation = "filesystem.write".into(); + let (status, body) = connected_authorization_post( + &backend_url, + &identity, + &child_ref, + &authorization_path(), + &serde_json::to_value(denied_write).unwrap(), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert_eq!( + fs::read_to_string(&readme).unwrap(), + "clean\n", + "denied Write has no effect on the actual Runtime materialization" + ); + let observations = authorizations.lock().unwrap(); + for (operation, expected) in [ + ("filesystem.read", StatusCode::OK), + ("filesystem.write", StatusCode::FORBIDDEN), + ] { + assert!( + observations + .iter() + .any(|(permission, status)| permission["operation"] == operation + && permission["resource"]["working_directory_id"] == workdir_id + && *status == expected), + "signed Host authorization must observe {operation} with {expected}: {observations:?}" + ); + } + assert!( + browser_ticket_backend(&api) + .unwrap() + .list(Default::default()) + .unwrap() + .is_empty(), + "generic Profile delegation requires no Ticket" + ); +} diff --git a/openapi/server-api.json b/openapi/server-api.json index 0efcf586e..720c13a72 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -18155,7 +18155,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083" + "x-yoi-source-digest": "sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7" }, "openapi": "3.1.0", "paths": { @@ -35399,6 +35399,9 @@ } }, "security": [ + { + "bearerAuth": [] + }, { "browserSession": [] } @@ -35511,6 +35514,9 @@ } }, "security": [ + { + "bearerAuth": [] + }, { "browserSession": [] } diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 3ebfe5b20..ecd2b3f5d 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index d3f934117..bc61338f5 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 769a81763..72647613a 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 048338ce9..18b0a6804 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index fdb476264..99db0b0a5 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 95bd9c108..8dd4a964b 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check