fix(workspace-config): fence user editors from runtime source proofs

Resolve MR finding WC-AUTH-001: reject authored source-tree and Profile editor requests from Runtime/Worker proofs in both scoped and single-Workspace middleware. Keep legitimate UI editing and independent Prompt/Skill/config-bundle consumption contracts. Add signed-router authority-state matrix and authenticated UI/WIP positive regressions.
This commit is contained in:
2026-10-06 07:14:31 +09:00
parent 0a471242cb
commit 2c023ac2be
3 changed files with 140 additions and 0 deletions
+31
View File
@@ -2579,6 +2579,23 @@ fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Re
status.into_response() 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( async fn authorize_scoped_workspace_request(
api: &WorkspaceServerApi, api: &WorkspaceServerApi,
workspace_id: &str, workspace_id: &str,
@@ -2639,6 +2656,13 @@ async fn authorize_scoped_workspace_request(
"invalid runtime request proof", "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); request.extensions_mut().insert(source);
if !matches!( if !matches!(
*request.method(), *request.method(),
@@ -2771,6 +2795,13 @@ async fn authorize_workspace_api_request(
"invalid runtime request proof", "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); request.extensions_mut().insert(source);
if !matches!( if !matches!(
*request.method(), *request.method(),
@@ -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,
&current.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] #[tokio::test]
async fn workspace_config_entrypoint_support_is_exact_and_canonical_mutations_stay_guarded() { async fn workspace_config_entrypoint_support_is_exact_and_canonical_mutations_stay_guarded() {
let fixture = manual_coder_assignment_fixture().await; let fixture = manual_coder_assignment_fixture().await;
@@ -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 substitute for authorization. A grant from another Workspace or another Worker
cannot be adopted by knowing a route or possessing a previous Interface. 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 The existing connection ledger remains authoritative. Each granted Worker has
an independently identified logical registry target over the **same** authored an independently identified logical registry target over the **same** authored
configuration tree. This preserves the ordinary Workdir exclusive-owner configuration tree. This preserves the ordinary Workdir exclusive-owner