diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 2aa6ecc63..cd4463fb6 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2579,6 +2579,23 @@ fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Re status.into_response() } +/// Authored configuration editors are user-facing authority, not a generic +/// Runtime workspace.request capability. Worker configuration access is only +/// through the grant- and connection-bound WIP adapter. Keep runtime projections +/// (Prompt/Skill/config bundles) on their existing, separate consumer contracts. +fn is_workspace_config_editor_path(path: &str) -> bool { + let Some((_, resource)) = path + .strip_prefix("/api/w/") + .and_then(|scoped| scoped.split_once('/')) + else { + return false; + }; + resource == "config/source-tree" + || resource.starts_with("config/source-tree/") + || resource == "settings/profiles" + || resource.starts_with("settings/profiles/") +} + async fn authorize_scoped_workspace_request( api: &WorkspaceServerApi, workspace_id: &str, @@ -2639,6 +2656,13 @@ async fn authorize_scoped_workspace_request( "invalid runtime request proof", ) })?; + if is_workspace_config_editor_path(&request_path) { + return Err(repository_api_rejection( + &request_path, + StatusCode::FORBIDDEN, + "Runtime configuration editor access denied", + )); + } request.extensions_mut().insert(source); if !matches!( *request.method(), @@ -2771,6 +2795,13 @@ async fn authorize_workspace_api_request( "invalid runtime request proof", ); }; + if is_workspace_config_editor_path(&request_path) { + return repository_api_rejection( + &request_path, + StatusCode::FORBIDDEN, + "Runtime configuration editor access denied", + ); + } request.extensions_mut().insert(source); if !matches!( *request.method(), diff --git a/crates/workspace-server/src/server_workspace_config_tests.rs b/crates/workspace-server/src/server_workspace_config_tests.rs index 628a16456..e65ae4e62 100644 --- a/crates/workspace-server/src/server_workspace_config_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_tests.rs @@ -127,6 +127,99 @@ mod workspace_config_integration { ) } + #[tokio::test] + async fn workspace_config_signed_editor_routes_cannot_bypass_wip_authority() { + let mut fixture = manual_coder_assignment_fixture().await; + let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap(); + configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); + let api = &fixture.api; + let initial = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap(); + let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree"); + let change = server_api::ConfigCommitRequest { + base_revision: initial.snapshot.revision, + base_digest: initial.snapshot.digest.clone(), + changes: vec![create("notes/editor-only.txt", "must not persist from Worker")], + entrypoints: vec!["main.dcdl".into()], + }; + let routes = [ + ("GET", tree.clone()), + ("GET", format!("{tree}/entries/main.dcdl")), + ("GET", format!("{tree}/revisions/{}", initial.snapshot.revision)), + ("POST", format!("{tree}/commit")), + ("GET", format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles")), + ]; + for state in ["ungranted", "read_only", "revoked", "detached", "read_write"] { + match state { + "read_only" => { + grant(api, &fixture.worker, Access::ReadOnly).await; + attach(api, &fixture.worker).await; + } + "revoked" => { + let current = api.store.current_workspace_config_grant(TEST_WORKSPACE_ID, &fixture.worker).unwrap().unwrap(); + workspace_config::revoke_grant(api, &test_owner_actor(), TEST_WORKSPACE_ID, + ¤t.grant_id).await.unwrap(); + } + "detached" => { + grant(api, &fixture.worker, Access::ReadWrite).await; + let attached = attach(api, &fixture.worker).await; + detach_current_worker_workdir(api, &fixture.worker, "workspace-config", + Some(&attached.connection_id)).await.unwrap(); + } + "read_write" => { attach(api, &fixture.worker).await; } + _ => {} + } + for (method, path) in &routes { + let body = if *method == "POST" { serde_json::to_vec(&change).unwrap() } else { vec![] }; + for scoped in [false, true] { + let app = if scoped { + workspace_server_router(WorkspaceServerApi::new(api.config.clone(), api.store.clone())) + } else { + build_router(api.clone()) + }; + let response = app.oneshot(runtime_source_request(&identity, + Some(&fixture.worker.worker_id), method, path, body.clone())).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, + "{state}: {method} {path}, scoped={scoped}"); + let bytes = to_bytes(response.into_body(), 4096).await.unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("must not persist")); + } + } + let after = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap(); + assert_eq!(after.snapshot, initial.snapshot, "{state}: editor rejection must have no effect"); + } + // A Runtime-only proof is not a user editor identity either. + assert_eq!(signed_call(api, &identity, None, "GET", &tree, Value::Null).await.0, + StatusCode::FORBIDDEN); + let attached = attach(api, &fixture.worker).await; + let observed = observe(api, &fixture.worker, &attached.connection_id, &["main.dcdl"], 0).await; + let node = observed.nodes.iter().find(|n| n.path == "main.dcdl").unwrap(); + assert!(workspace_config::read(api, &fixture.worker, Read { + connection_id: attached.connection_id, + path: "main.dcdl".into(), validator: node.validator.clone(), + }).await.is_ok(), "the granted WIP path remains available"); + + // Real API-token authentication still permits the normal UI editor. + let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + for (method, path) in &routes { + if *method == "GET" { + request_json_authenticated(build_router(api.clone()), method, path, None, + &token, StatusCode::OK).await; + } + } + request_json_authenticated(build_router(api.clone()), "POST", &format!("{tree}/commit"), + Some(serde_json::to_value(change).unwrap()), &token, StatusCode::CREATED).await; + let saved = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap(); + assert_eq!(saved.snapshot.revision, initial.snapshot.revision + 1); + assert!(saved.snapshot.entries.keys().any(|p| p.as_str() == "notes/editor-only.txt")); + // Prompt and Skill runtime consumption are separate read-only projections, + // not authored-tree editor APIs; bootstrap must remain usable. + assert_eq!(signed_call(api, &identity, Some(&fixture.worker.worker_id), "GET", + &format!("/api/w/{TEST_WORKSPACE_ID}/config/projections/prompts"), Value::Null).await.0, + StatusCode::OK); + assert_eq!(signed_call(api, &identity, Some(&fixture.worker.worker_id), "GET", + &format!("/api/w/{TEST_WORKSPACE_ID}/skills"), Value::Null).await.0, StatusCode::OK); + } + #[tokio::test] async fn workspace_config_entrypoint_support_is_exact_and_canonical_mutations_stay_guarded() { let fixture = manual_coder_assignment_fixture().await; diff --git a/docs/design/workspace-config-wip-projection.md b/docs/design/workspace-config-wip-projection.md index 768655b3a..a8dfc977c 100644 --- a/docs/design/workspace-config-wip-projection.md +++ b/docs/design/workspace-config-wip-projection.md @@ -89,6 +89,22 @@ ID. Worker requests carry authenticated source identity, never these IDs as a substitute for authorization. A grant from another Workspace or another Worker cannot be adopted by knowing a route or possessing a previous Interface. +Explicit grants do not replace the UI editor's user authentication. Authored +source-tree read/entry/history/commit routes and the UI Profile settings catalog +reject Runtime/Worker source proofs in both single-Workspace and server-scoped +middleware, including workers with a read-write grant. `workspace.request` is +not a configuration-editor capability: granted Workers use the WIP adapter, +never the UI editor as an alternate access path. User/session/API-token editing +continues to share the canonical validation and CAS boundary with WIP saves. + +The audit found no separate Profile/Prompt/Runtime/Skill source-save endpoint; +authored writes converge on source-tree commit. Existing runtime consumption of +Prompt projections, Skill catalog/activation and resolved config bundles remains +a separate read-only/activation contract needed for Worker startup and normal +operation. Those evaluated projections are not authored source-tree bodies, +revision history or a second editor. Do not fence all `/config` routes as one +capability: that would break the existing Prompt reader's runtime contract. + The existing connection ledger remains authoritative. Each granted Worker has an independently identified logical registry target over the **same** authored configuration tree. This preserves the ordinary Workdir exclusive-owner