runtime: scope worker access by workspace

This commit is contained in:
2026-07-30 00:25:46 +09:00
parent 0ff8a95da3
commit 3dda06cbe3
5 changed files with 754 additions and 59 deletions
+360 -58
View File
@@ -8,7 +8,8 @@
use crate::Runtime;
use crate::auth::{
RuntimeAuthContext, RuntimeHttpAuthConfig, unix_now_seconds, verify_capability_token,
RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig, unix_now_seconds,
verify_capability_token,
};
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
@@ -25,7 +26,7 @@ use axum::body::{Body, Bytes};
use axum::extract::rejection::{JsonRejection, QueryRejection};
#[cfg(feature = "ws-server")]
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{Method, Request, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
@@ -383,12 +384,20 @@ async fn check_config_bundle(
async fn list_workers(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
query: Result<Query<RuntimeHttpWorkersQuery>, QueryRejection>,
) -> RestResult<RuntimeHttpWorkersResponse> {
let Query(query) = query.map_err(RuntimeHttpRestError::query_rejection)?;
let workers = match query.status {
Some(RuntimeHttpWorkerStatusFilter::Stopped) => state.runtime.list_stopped_workers(),
None => state.runtime.list_workers(),
let workspace_id = auth_workspace_id(&state, auth.as_ref())?;
let workers = match (query.status, workspace_id) {
(Some(RuntimeHttpWorkerStatusFilter::Stopped), Some(workspace_id)) => {
state.runtime.list_stopped_workers_scoped(workspace_id)
}
(Some(RuntimeHttpWorkerStatusFilter::Stopped), None) => {
state.runtime.list_stopped_workers()
}
(None, Some(workspace_id)) => state.runtime.list_workers_scoped(workspace_id),
(None, None) => state.runtime.list_workers(),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkersResponse { workers }))
@@ -448,67 +457,87 @@ async fn cleanup_working_directory(
async fn get_worker(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>,
) -> RestResult<RuntimeHttpWorkerResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let worker = state
.runtime
.worker_detail(&worker_ref)
.map_err(RuntimeHttpRestError::runtime)?;
let worker = match auth_workspace_id(&state, auth.as_ref())? {
Some(workspace_id) => state
.runtime
.worker_detail_scoped(workspace_id, &worker_ref),
None => state.runtime.worker_detail(&worker_ref),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerResponse { worker }))
}
async fn delete_worker(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>,
) -> RestResult<RuntimeHttpWorkerDeleteResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let worker = state
.runtime
.delete_worker(&worker_ref)
.map_err(RuntimeHttpRestError::runtime)?;
let worker = match auth_workspace_id(&state, auth.as_ref())? {
Some(workspace_id) => state
.runtime
.delete_worker_scoped(workspace_id, &worker_ref),
None => state.runtime.delete_worker(&worker_ref),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerDeleteResponse { worker }))
}
async fn create_worker(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
body: Result<Json<CreateWorkerRequest>, JsonRejection>,
) -> RestResult<RuntimeHttpWorkerResponse> {
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
let worker = state
.runtime
.create_worker(request)
.map_err(RuntimeHttpRestError::runtime)?;
let worker = match auth_workspace_id(&state, auth.as_ref())? {
Some(workspace_id) => state.runtime.create_worker_scoped(workspace_id, request),
None => state.runtime.create_worker(request),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerResponse { worker }))
}
async fn restore_worker(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>,
) -> RestResult<RuntimeHttpWorkerResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let worker = state
.runtime
.restore_worker(&worker_ref)
.map_err(RuntimeHttpRestError::runtime)?;
let worker = match auth_workspace_id(&state, auth.as_ref())? {
Some(workspace_id) => state
.runtime
.restore_worker_scoped(workspace_id, &worker_ref),
None => state.runtime.restore_worker(&worker_ref),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerResponse { worker }))
}
#[cfg(feature = "ws-server")]
async fn worker_protocol_ws(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>,
Query(query): Query<RuntimeWorkerEventsWsQuery>,
ws: WebSocketUpgrade,
) -> Result<Response, RuntimeHttpRestError> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
state
.runtime
.worker_detail(&worker_ref)
.map_err(RuntimeHttpRestError::runtime)?;
let workspace_id = auth_workspace_id(&state, auth.as_ref())?.map(ToOwned::to_owned);
match workspace_id.as_deref() {
Some(workspace_id) => state
.runtime
.worker_detail_scoped(workspace_id, &worker_ref)
.map(|_| ()),
None => state.runtime.worker_detail(&worker_ref).map(|_| ()),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(ws
.on_upgrade(move |socket| {
worker_protocol_ws_session(state.runtime, worker_ref, query, socket)
worker_protocol_ws_session(state.runtime, workspace_id, worker_ref, query, socket)
})
.into_response())
}
@@ -516,6 +545,7 @@ async fn worker_protocol_ws(
#[cfg(feature = "ws-server")]
async fn worker_protocol_ws_session(
runtime: Runtime,
workspace_id: Option<String>,
worker_ref: WorkerRef,
query: RuntimeWorkerEventsWsQuery,
mut socket: WebSocket,
@@ -599,20 +629,30 @@ async fn worker_protocol_ws_session(
inbound = socket.next() => {
match inbound {
Some(Ok(WsMessage::Text(text))) => match decode_method(&text) {
Ok(method) => match runtime.send_protocol_method(&worker_ref, method) {
Ok(events) => {
for event in events {
Ok(method) => {
let result = match workspace_id.as_deref() {
Some(workspace_id) => runtime.send_protocol_method_scoped(
workspace_id,
&worker_ref,
method,
),
None => runtime.send_protocol_method(&worker_ref, method),
};
match result {
Ok(events) => {
for event in events {
if !send_protocol_event(&mut socket, &event).await {
return;
}
}
}
Err(error) => {
let event = protocol_error_event(error.to_string());
if !send_protocol_event(&mut socket, &event).await {
return;
}
}
}
Err(error) => {
let event = protocol_error_event(error.to_string());
if !send_protocol_event(&mut socket, &event).await {
return;
}
}
},
Err(error) => {
let event = protocol_error_event(format!(
@@ -688,29 +728,42 @@ fn protocol_error_event(message: impl Into<String>) -> protocol::Event {
async fn send_worker_input(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>,
body: Result<Json<WorkerInput>, JsonRejection>,
) -> RestResult<RuntimeHttpWorkerInputResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let Json(input) = body.map_err(RuntimeHttpRestError::json_rejection)?;
let ack = state
.runtime
.send_input(&worker_ref, input)
.map_err(RuntimeHttpRestError::runtime)?;
let ack = match auth_workspace_id(&state, auth.as_ref())? {
Some(workspace_id) => state
.runtime
.send_input_scoped(workspace_id, &worker_ref, input),
None => state.runtime.send_input(&worker_ref, input),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerInputResponse { ack }))
}
async fn worker_completions(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>,
body: Result<Json<RuntimeHttpWorkerCompletionsRequest>, JsonRejection>,
) -> RestResult<RuntimeHttpWorkerCompletionsResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
let entries = state
.runtime
.worker_completions(&worker_ref, request.kind, &request.prefix)
.map_err(RuntimeHttpRestError::runtime)?;
let entries = match auth_workspace_id(&state, auth.as_ref())? {
Some(workspace_id) => state.runtime.worker_completions_scoped(
workspace_id,
&worker_ref,
request.kind,
&request.prefix,
),
None => state
.runtime
.worker_completions(&worker_ref, request.kind, &request.prefix),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerCompletionsResponse {
kind: request.kind,
prefix: request.prefix,
@@ -720,29 +773,41 @@ async fn worker_completions(
async fn stop_worker(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>,
body: Bytes,
) -> RestResult<RuntimeHttpWorkerLifecycleResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let request = parse_optional_lifecycle_request(body)?;
let ack = state
.runtime
.stop_worker(&worker_ref, request.reason)
.map_err(RuntimeHttpRestError::runtime)?;
let ack = match auth_workspace_id(&state, auth.as_ref())? {
Some(workspace_id) => {
state
.runtime
.stop_worker_scoped(workspace_id, &worker_ref, request.reason)
}
None => state.runtime.stop_worker(&worker_ref, request.reason),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
}
async fn cancel_worker(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>,
body: Bytes,
) -> RestResult<RuntimeHttpWorkerLifecycleResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let request = parse_optional_lifecycle_request(body)?;
let ack = state
.runtime
.cancel_worker(&worker_ref, request.reason)
.map_err(RuntimeHttpRestError::runtime)?;
let ack = match auth_workspace_id(&state, auth.as_ref())? {
Some(workspace_id) => {
state
.runtime
.cancel_worker_scoped(workspace_id, &worker_ref, request.reason)
}
None => state.runtime.cancel_worker(&worker_ref, request.reason),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
}
@@ -806,12 +871,7 @@ async fn require_runtime_auth(
return next.run(request).await;
}
Err(error) => {
return RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"unauthorized",
format!("invalid Runtime capability token: {error}"),
)
.into_response();
return runtime_auth_error_response(error).into_response();
}
}
}
@@ -836,6 +896,51 @@ async fn require_runtime_auth(
next.run(request).await
}
fn runtime_auth_error_response(error: RuntimeAuthError) -> RuntimeHttpRestError {
match error {
RuntimeAuthError::MissingPermission(permission) => RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"forbidden",
format!("Runtime capability token is missing required permission `{permission}`"),
),
RuntimeAuthError::MissingWorkspaceScope => RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_scope_required",
"Runtime capability token is missing workspace scope",
),
other => RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"unauthorized",
format!("invalid Runtime capability token: {other}"),
),
}
}
fn auth_workspace_id<'a>(
state: &RuntimeHttpState,
auth: Option<&'a Extension<RuntimeAuthContext>>,
) -> Result<Option<&'a str>, RuntimeHttpRestError> {
let Some(Extension(context)) = auth else {
if state.auth.is_some() || state.local_token.is_some() {
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_scope_required",
"Runtime worker operation requires a workspace-scoped authorization context",
));
}
return Ok(None);
};
let workspace_id = context.workspace_id.trim();
if workspace_id.is_empty() {
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_scope_required",
"Runtime worker operation requires a non-empty workspace scope",
));
}
Ok(Some(workspace_id))
}
fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static str> {
if path == "/v1/runtime" {
return None;
@@ -991,7 +1096,11 @@ pub enum RuntimeHttpServerError {
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkerStatus};
use crate::auth::{
CapabilityTokenSigner, RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey,
capability_claims,
};
use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkerStatus, WorkspaceApiRef};
use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
};
@@ -1029,6 +1138,199 @@ mod tests {
.with_computed_digest()
}
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
let mut request = task_request(objective);
request.workspace_api = Some(WorkspaceApiRef {
workspace_id: workspace_id.to_string(),
base_url: format!("https://workspace.example/{workspace_id}"),
});
request
}
fn auth_config_and_signer() -> (RuntimeHttpAuthConfig, CapabilityTokenSigner) {
let identity = RuntimeIdentityMaterial::generate("server-a").unwrap();
let signer = CapabilityTokenSigner::new(identity.identity_id.clone(), identity.private_key);
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-test".to_string(),
trusted_servers: vec![TrustedServerKey {
server_id: identity.identity_id,
public_key: identity.public_key,
display_name: None,
}],
};
(auth, signer)
}
fn token_for_workspace(signer: &CapabilityTokenSigner, workspace_id: &str) -> String {
token_for_workspace_with_permissions(
signer,
workspace_id,
[
"workers:list",
"workers:create",
"workers:read",
"workers:input",
"workers:stop",
"workers:protocol",
"workers:delete",
],
)
}
fn token_for_workspace_with_permissions<const N: usize>(
signer: &CapabilityTokenSigner,
workspace_id: &str,
permissions: [&str; N],
) -> String {
let claims = capability_claims(
signer.server_id(),
"runtime-test",
workspace_id,
permissions.into_iter().map(str::to_string).collect(),
3600,
)
.unwrap();
signer.sign(&claims).unwrap()
}
fn bearer_request(
method: Method,
uri: impl AsRef<str>,
token: &str,
body: impl Into<Body>,
) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri.as_ref())
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(body.into())
.unwrap()
}
#[tokio::test]
async fn capability_workspace_scope_filters_list_and_hides_detail() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let (auth, signer) = auth_config_and_signer();
let token_a = token_for_workspace(&signer, "workspace-a");
let token_b = token_for_workspace(&signer, "workspace-b");
let app = runtime_http_router_with_auth(runtime, None, Some(auth));
let create_a = scoped_task_request("a", "workspace-a");
let response = app
.clone()
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token_a,
serde_json::to_vec(&create_a).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let worker_a: RuntimeHttpWorkerResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(worker_a.worker.workspace_id.as_deref(), Some("workspace-a"));
let create_b = scoped_task_request("b", "workspace-b");
let response = app
.clone()
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token_b,
serde_json::to_vec(&create_b).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let worker_b: RuntimeHttpWorkerResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(worker_b.worker.workspace_id.as_deref(), Some("workspace-b"));
let response = app
.clone()
.oneshot(bearer_request(
Method::GET,
"/v1/workers",
&token_a,
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let workers: RuntimeHttpWorkersResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(workers.workers.len(), 1);
assert_eq!(workers.workers[0].worker_ref, worker_a.worker.worker_ref);
assert_eq!(
workers.workers[0].workspace_id.as_deref(),
Some("workspace-a")
);
let response = app
.oneshot(bearer_request(
Method::GET,
format!("/v1/workers/{}", worker_b.worker.worker_id),
&token_a,
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn capability_token_without_workspace_scope_is_forbidden() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let (auth, signer) = auth_config_and_signer();
let token = token_for_workspace_with_permissions(&signer, "", ["workers:list"]);
let app = runtime_http_router_with_auth(runtime, None, Some(auth));
let response = app
.oneshot(bearer_request(
Method::GET,
"/v1/workers",
&token,
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn capability_token_without_worker_permission_is_forbidden() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let (auth, signer) = auth_config_and_signer();
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:list"]);
let app = runtime_http_router_with_auth(runtime, None, Some(auth));
let create = scoped_task_request("a", "workspace-a");
let response = app
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token,
serde_json::to_vec(&create).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
fn task_request(_objective: &str) -> CreateWorkerRequest {
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
let bundle = test_bundle(profile.clone());