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()
}
/// 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(),
@@ -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]
async fn workspace_config_entrypoint_support_is_exact_and_canonical_mutations_stay_guarded() {
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
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