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()
|
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,
|
||||||
|
¤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]
|
#[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
|
||||||
|
|||||||
Reference in New Issue
Block a user