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:
@@ -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,
|
||||
¤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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user