refactor: remove server-global runtime trust

This commit is contained in:
2026-09-08 12:19:39 +09:00
parent fae36d220d
commit 3344d9f8b2
19 changed files with 269 additions and 2714 deletions
+4 -16
View File
@@ -12,10 +12,10 @@ use workspace_api::{
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse,
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
};
use crate::{BackendApiClient, BackendWorkspaceClientError};
@@ -266,18 +266,6 @@ impl BackendWorkspaceProductClient {
))
}
pub fn put_runtime_trust_key(
&self,
runtime_id: &str,
request: &PutRuntimeTrustKeyRequest,
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
self.send_json(
Method::PUT,
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
Some(request),
)
}
pub fn revoke_runtime_trust_key(
&self,
runtime_id: &str,
-185
View File
@@ -10,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
const TOKEN_PREFIX: &str = "yoi-cap-v1";
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
@@ -164,21 +162,6 @@ impl RuntimeIdentityMaterial {
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedServerKey {
pub server_id: String,
pub public_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpAuthConfig {
pub runtime_id: String,
#[serde(default)]
pub trusted_servers: Vec<TrustedServerKey>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthContext {
pub server_id: String,
@@ -188,122 +171,6 @@ pub struct RuntimeAuthContext {
pub expires_at: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityClaims {
pub iss: String,
pub aud: String,
pub workspace_id: String,
pub permissions: Vec<String>,
pub exp: u64,
pub jti: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapabilityTokenSigner {
server_id: String,
private_key: String,
}
impl CapabilityTokenSigner {
pub fn new(server_id: impl Into<String>, private_key: impl Into<String>) -> Self {
Self {
server_id: server_id.into(),
private_key: private_key.into(),
}
}
pub fn server_id(&self) -> &str {
&self.server_id
}
pub fn sign(&self, claims: &CapabilityClaims) -> Result<String, RuntimeAuthError> {
if claims.iss != self.server_id {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
}
let private = decode_private_key(&self.private_key)?;
let pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let payload = serde_json::to_vec(claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
let signature = pair.sign(signing_input.as_bytes());
Ok(format!(
"{TOKEN_PREFIX}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
}
pub fn capability_claims(
server_id: impl Into<String>,
runtime_id: impl Into<String>,
workspace_id: impl Into<String>,
permissions: Vec<String>,
ttl_seconds: u64,
) -> Result<CapabilityClaims, RuntimeAuthError> {
let exp = unix_now_seconds().saturating_add(ttl_seconds);
Ok(CapabilityClaims {
iss: server_id.into(),
aud: runtime_id.into(),
workspace_id: workspace_id.into(),
permissions,
exp,
jti: new_token_id()?,
})
}
pub fn verify_capability_token(
config: &RuntimeHttpAuthConfig,
token: &str,
required_permission: Option<&str>,
now_seconds: u64,
) -> Result<RuntimeAuthContext, RuntimeAuthError> {
let (payload, signature) = split_token(token)?;
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
let claims: CapabilityClaims = serde_json::from_slice(&claims_json)?;
let Some(server) = config
.trusted_servers
.iter()
.find(|server| server.server_id == claims.iss)
else {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
};
let public_key = decode_public_key(&server.public_key)?;
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
if claims.aud != config.runtime_id {
return Err(RuntimeAuthError::WrongAudience {
expected: config.runtime_id.clone(),
actual: claims.aud,
});
}
if claims.exp < now_seconds {
return Err(RuntimeAuthError::Expired);
}
if claims.workspace_id.trim().is_empty() {
return Err(RuntimeAuthError::MissingWorkspaceScope);
}
if let Some(required) = required_permission {
if !claims
.permissions
.iter()
.any(|permission| permission == required)
{
return Err(RuntimeAuthError::MissingPermission(required.to_string()));
}
}
Ok(RuntimeAuthContext {
server_id: claims.iss,
workspace_id: claims.workspace_id,
permissions: claims.permissions,
token_id: claims.jti,
expires_at: claims.exp,
})
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeRequestSourceClaims {
pub iss: String,
@@ -638,16 +505,6 @@ fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec<u8>), Ru
}
}
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
let mut parts = token.split('.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(prefix), Some(payload), Some(signature), None) if prefix == TOKEN_PREFIX => {
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
}
_ => Err(RuntimeAuthError::InvalidTokenFormat),
}
}
pub fn encode_public_key(bytes: &[u8]) -> String {
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
}
@@ -903,46 +760,4 @@ mod tests {
Err(RuntimeAuthError::Expired)
));
}
#[test]
fn capability_token_verifies_signature_audience_expiry_and_permission() {
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
let signer = CapabilityTokenSigner::new(&server.identity_id, &server.private_key);
let claims = CapabilityClaims {
iss: "server-main".to_string(),
aud: "runtime-main".to_string(),
workspace_id: "workspace-a".to_string(),
permissions: vec!["workers:list".to_string()],
exp: 100,
jti: "token-1".to_string(),
};
let token = signer.sign(&claims).unwrap();
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-main".to_string(),
trusted_servers: vec![TrustedServerKey {
server_id: "server-main".to_string(),
public_key: server.public_key.clone(),
display_name: None,
}],
};
let context = verify_capability_token(&auth, &token, Some("workers:list"), 99).unwrap();
assert_eq!(context.workspace_id, "workspace-a");
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:create"), 99),
Err(RuntimeAuthError::MissingPermission(permission)) if permission == "workers:create"
));
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:list"), 101),
Err(RuntimeAuthError::Expired)
));
let wrong_audience = RuntimeHttpAuthConfig {
runtime_id: "other-runtime".to_string(),
trusted_servers: auth.trusted_servers.clone(),
};
assert!(matches!(
verify_capability_token(&wrong_audience, &token, Some("workers:list"), 99),
Err(RuntimeAuthError::WrongAudience { .. })
));
}
}
+10 -501
View File
@@ -6,10 +6,7 @@
//! Runtime process directly; a backend is expected to own any browser-facing
//! credentials, registration, and policy.
use crate::auth::{
RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig, new_token_id, unix_now_seconds,
verify_capability_token,
};
use crate::auth::{RuntimeAuthContext, new_token_id, unix_now_seconds};
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, RepositoryRefObservationRequest, WorkerDetail,
WorkerLifecycleAck, WorkerSummary, WorkingDirectoryRepositoryAccessRequest,
@@ -95,11 +92,9 @@ pub struct RuntimeHttpServerConfig {
pub display_name: Option<String>,
/// v0 store selection for the Runtime process.
pub store: RuntimeHttpStoreSelection,
/// Minimal local bearer token placeholder for backend-to-Runtime calls.
/// Minimal local bearer token for explicitly local Runtime calls.
/// This is not a browser-facing credential model.
pub local_token: Option<String>,
/// Optional signed Server-to-Runtime capability token authority.
pub auth: Option<RuntimeHttpAuthConfig>,
}
impl Default for RuntimeHttpServerConfig {
@@ -109,7 +104,6 @@ impl Default for RuntimeHttpServerConfig {
display_name: None,
store: RuntimeHttpStoreSelection::Memory,
local_token: None,
auth: None,
}
}
}
@@ -152,34 +146,15 @@ pub async fn serve_runtime_http(
Ok(())
}
/// Serve an existing Runtime on a pre-bound listener with signed capability-token auth.
pub async fn serve_runtime_http_with_auth(
runtime: Runtime,
listener: TcpListener,
local_token: Option<String>,
auth: Option<RuntimeHttpAuthConfig>,
) -> Result<(), RuntimeHttpServerError> {
if local_token.is_none() && auth.is_none() {
return Err(RuntimeHttpServerError::AuthRequired);
}
axum::serve(
listener,
runtime_http_router_with_optional_auth(runtime, local_token, auth, None),
)
.await?;
Ok(())
}
pub async fn serve_runtime_http_with_workspace_auth(
runtime: Runtime,
listener: TcpListener,
local_token: Option<String>,
auth: Option<RuntimeHttpAuthConfig>,
workspace_auth: WorkspaceRuntimeHttpAuth,
) -> Result<(), RuntimeHttpServerError> {
axum::serve(
listener,
runtime_http_router_with_optional_auth(runtime, local_token, auth, Some(workspace_auth)),
runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth)),
)
.await?;
Ok(())
@@ -191,37 +166,25 @@ pub async fn serve_runtime_http_with_workspace_auth(
/// The path contains only a Runtime-local `worker_id`; backend aliases are not
/// accepted or forwarded as Runtime authority.
pub fn runtime_http_router(runtime: Runtime, local_token: String) -> Router {
runtime_http_router_with_optional_auth(runtime, Some(local_token), None, None)
}
/// Build the REST router for an existing Runtime with signed capability-token auth.
pub fn runtime_http_router_with_auth(
runtime: Runtime,
local_token: Option<String>,
auth: RuntimeHttpAuthConfig,
) -> Router {
runtime_http_router_with_optional_auth(runtime, local_token, Some(auth), None)
runtime_http_router_with_optional_auth(runtime, Some(local_token), None)
}
pub fn runtime_http_router_with_workspace_auth(
runtime: Runtime,
local_token: Option<String>,
auth: RuntimeHttpAuthConfig,
workspace_auth: WorkspaceRuntimeHttpAuth,
) -> Router {
runtime_http_router_with_optional_auth(runtime, local_token, Some(auth), Some(workspace_auth))
runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth))
}
fn runtime_http_router_with_optional_auth(
runtime: Runtime,
local_token: Option<String>,
auth: Option<RuntimeHttpAuthConfig>,
workspace_auth: Option<WorkspaceRuntimeHttpAuth>,
) -> Router {
let state = RuntimeHttpState {
runtime,
local_token: local_token.map(Arc::<str>::from),
auth: auth.map(Arc::new),
workspace_auth: workspace_auth.map(Arc::new),
workdir_sessions: Arc::new(Mutex::new(HashMap::new())),
};
@@ -328,7 +291,6 @@ pub const MAX_WORKER_FILE_UPLOAD_BYTES: usize =
struct RuntimeHttpState {
runtime: Runtime,
local_token: Option<Arc<str>>,
auth: Option<Arc<RuntimeHttpAuthConfig>>,
workspace_auth: Option<Arc<WorkspaceRuntimeHttpAuth>>,
workdir_sessions: Arc<Mutex<HashMap<String, RuntimeHttpWorkdirSession>>>,
}
@@ -709,9 +671,9 @@ async fn get_runtime_ping(
));
}
let runtime_id = state
.auth
.workspace_auth
.as_ref()
.map(|config| config.runtime_id.trim())
.map(|auth| auth.signer.runtime_id().trim())
.filter(|runtime_id| !runtime_id.is_empty())
.ok_or_else(|| {
RuntimeHttpRestError::new(
@@ -2110,56 +2072,6 @@ async fn require_runtime_auth(
}
}
if let Some(auth) = state.auth.as_deref() {
let Some(token) = supplied.as_deref() else {
return RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"unauthorized",
"missing Runtime capability bearer token",
)
.into_response();
};
match verify_capability_token(
auth,
token,
required_runtime_permission(request.method(), request.uri().path()),
unix_now_seconds(),
) {
Ok(context) => {
let workspace_verification_exists = match state.workspace_auth.as_deref() {
Some(workspace_auth) => match workspace_auth
.verifications
.get(&context.workspace_id, workspace_auth.signer.runtime_id())
{
Ok(record) => record.is_some(),
Err(error) => {
return RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE,
"workspace_runtime_verification_unavailable",
error.to_string(),
)
.into_response();
}
},
None => false,
};
if workspace_verification_exists {
return RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_identity_required",
"Legacy Server-issued capability is disabled after signed Workspace Runtime verification",
)
.into_response();
}
request.extensions_mut().insert(context);
return next.run(request).await;
}
Err(error) => {
return runtime_auth_error_response(error).into_response();
}
}
}
if let Some(expected) = state.local_token.as_deref() {
if supplied.as_deref() != Some(expected) {
return RuntimeHttpRestError::new(
@@ -2180,32 +2092,12 @@ 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_scope(
state: &RuntimeHttpState,
auth: Option<&Extension<RuntimeAuthContext>>,
) -> Result<Option<RuntimeWorkspaceScope>, RuntimeHttpRestError> {
let Some(Extension(context)) = auth else {
if state.auth.is_some() || state.local_token.is_some() {
if state.workspace_auth.is_some() || state.local_token.is_some() {
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_scope_required",
@@ -2537,7 +2429,7 @@ fn code_for_runtime_error(error: &RuntimeError) -> String {
pub enum RuntimeHttpServerError {
#[error(transparent)]
Runtime(#[from] RuntimeError),
#[error("Runtime HTTP server requires capability-token auth or a local bearer token")]
#[error("Runtime HTTP server requires Workspace issuer auth or a local bearer token")]
AuthRequired,
#[error("Runtime HTTP server I/O failed: {0}")]
Io(#[from] std::io::Error),
@@ -2546,10 +2438,7 @@ pub enum RuntimeHttpServerError {
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{
CapabilityTokenSigner, RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey,
capability_claims,
};
use crate::auth::RuntimeIdentityMaterial;
use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkerStatus, WorkspaceApiRef};
use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
@@ -2579,7 +2468,6 @@ mod tests {
#[tokio::test]
async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() {
let runtime = Runtime::new_memory();
let (legacy_auth, _) = auth_config_and_signer();
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let workspace_public_key =
@@ -2614,7 +2502,6 @@ mod tests {
let app = runtime_http_router_with_workspace_auth(
runtime,
None,
legacy_auth,
WorkspaceRuntimeHttpAuth {
verifier,
signer: RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap(),
@@ -2764,82 +2651,6 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn ping_requires_scoped_permission_and_returns_versioned_identity() {
let runtime = Runtime::new_memory();
let (auth, signer) = auth_config_and_signer();
let app = runtime_http_router_with_auth(runtime, None, auth);
let token =
token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]);
let request = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
serde_json::from_slice::<RuntimeHttpPingResponse>(&body).unwrap(),
RuntimeHttpPingResponse {
runtime_id: "runtime-test".to_string(),
protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION,
}
);
let wrong_scope_token =
token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]);
let wrong_scope_request = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {wrong_scope_token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-b")
.body(Body::empty())
.unwrap();
assert_eq!(
app.oneshot(wrong_scope_request).await.unwrap().status(),
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn ping_rejects_token_without_ping_permission() {
let runtime = Runtime::new_memory();
let (auth, signer) = auth_config_and_signer();
let app = runtime_http_router_with_auth(runtime, None, auth);
let missing_credential = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap();
assert_eq!(
app.clone()
.oneshot(missing_credential)
.await
.unwrap()
.status(),
StatusCode::UNAUTHORIZED
);
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:read"]);
let request = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap();
assert_eq!(
app.oneshot(request).await.unwrap().status(),
StatusCode::FORBIDDEN
);
}
#[test]
fn runtime_protocol_replaces_serialized_tracked_source() {
let wire = serde_json::to_string(&protocol::Method::SubmitTracked {
@@ -2937,301 +2748,6 @@ mod tests {
.with_computed_digest()
}
fn store_coder_test_bundle(runtime: &Runtime) {
runtime
.store_config_bundle(test_bundle(ProfileSelector::Builtin(
"builtin:coder".to_string(),
)))
.unwrap();
}
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.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: workspace_id.to_string(),
settings_revision: 1,
language: "English".to_string(),
});
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 auth_config_and_two_signers() -> (
RuntimeHttpAuthConfig,
CapabilityTokenSigner,
CapabilityTokenSigner,
) {
let identity_a = RuntimeIdentityMaterial::generate("server-a").unwrap();
let identity_b = RuntimeIdentityMaterial::generate("server-b").unwrap();
let signer_a =
CapabilityTokenSigner::new(identity_a.identity_id.clone(), identity_a.private_key);
let signer_b =
CapabilityTokenSigner::new(identity_b.identity_id.clone(), identity_b.private_key);
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-test".to_string(),
trusted_servers: vec![
TrustedServerKey {
server_id: identity_a.identity_id,
public_key: identity_a.public_key,
display_name: None,
},
TrustedServerKey {
server_id: identity_b.identity_id,
public_key: identity_b.public_key,
display_name: None,
},
],
};
(auth, signer_a, signer_b)
}
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",
"workdirs:operate",
],
)
}
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();
store_coder_test_bundle(&runtime);
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, 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_workspace_owner_binding_rejects_other_trusted_server() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
store_coder_test_bundle(&runtime);
let (auth, signer_a, signer_b) = auth_config_and_two_signers();
let token_a = token_for_workspace(&signer_a, "workspace-a");
let token_b = token_for_workspace(&signer_b, "workspace-a");
let app = runtime_http_router_with_auth(runtime, None, 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 create_b = scoped_task_request("b", "workspace-a");
let response = app
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token_b,
serde_json::to_vec(&create_b).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[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, 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, 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);
}
#[tokio::test]
async fn capability_token_without_workdir_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:read"]);
let app = runtime_http_router_with_auth(runtime, None, auth);
let response = app
.oneshot(bearer_request(
Method::POST,
"/v1/working-directories/wd-1/sessions",
&token,
serde_json::to_vec(&OpenWorkdirSessionRequest::default()).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());
@@ -3331,7 +2847,6 @@ mod tests {
)
.expect("runtime"),
local_token: Some(Arc::from("token")),
auth: None,
workspace_auth: None,
workdir_sessions: Arc::new(Mutex::new(HashMap::from([(
"session-1".to_string(),
@@ -3708,12 +3223,6 @@ mod tests {
.await
.unwrap_err();
assert!(matches!(error, RuntimeHttpServerError::AuthRequired));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let error = serve_runtime_http_with_auth(Runtime::new_memory(), listener, None, None)
.await
.unwrap_err();
assert!(matches!(error, RuntimeHttpServerError::AuthRequired));
}
#[test]
+49 -228
View File
@@ -15,9 +15,9 @@ use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use worker_runtime::auth::{
RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, decode_public_key,
};
use worker_runtime::auth::RuntimeIdentityMaterial;
#[cfg(test)]
use worker_runtime::auth::decode_public_key;
use worker_runtime::error::RuntimeError;
use worker_runtime::fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
use worker_runtime::http_server::{
@@ -81,16 +81,15 @@ fn run() -> Result<(), ProcessError> {
}
if matches!(
args.first().map(String::as_str),
Some("identity" | "trust-server" | "trust-workspace")
Some("identity" | "trust-workspace")
) {
return run_auth_command(args);
}
let Some(mut config) = parse_args(args)? else {
let Some(config) = parse_args(args)? else {
println!("{}", usage());
return Ok(());
};
init_serve_tracing();
config.http.auth = load_runtime_http_auth(&config)?;
let workspace_http_auth = load_workspace_runtime_http_auth(&config)?;
let runtime = tokio::runtime::Builder::new_current_thread()
@@ -108,16 +107,19 @@ fn run() -> Result<(), ProcessError> {
worker_runtime,
listener,
config.http.local_token,
config.http.auth,
workspace_auth,
)
.await
} else {
worker_runtime::http_server::serve_runtime_http_with_auth(
let local_token = config.http.local_token.ok_or_else(|| {
ProcessError::auth(
"Runtime HTTP server requires Workspace issuer auth or --local-token".to_owned(),
)
})?;
worker_runtime::http_server::serve_runtime_http(
worker_runtime,
listener,
config.http.local_token,
config.http.auth,
Some(local_token),
)
.await
};
@@ -207,12 +209,7 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
.with_runtime_store_dir(runtime_store_dir);
let runtime_auth = read_runtime_auth_file(&runtime_auth_path(config))?;
if let Some(identity) = runtime_auth.identity.clone() {
if let [trusted_server] = runtime_auth.trusted_servers.as_slice() {
factory =
factory.with_runtime_request_identity(identity, trusted_server.server_id.clone());
} else {
factory = factory.with_remote_worker_mutation_identity(identity);
}
factory = factory.with_remote_worker_mutation_identity(identity);
}
let mut backend_resource_client: Option<
Arc<dyn worker_runtime::resource::BackendResourceClient>,
@@ -223,9 +220,9 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
"--backend-resource-endpoint requires a configured Runtime identity".to_owned(),
)
})?;
let [trusted_server] = runtime_auth.trusted_servers.as_slice() else {
let [workspace_issuer] = runtime_auth.workspace_issuers.as_slice() else {
return Err(ProcessError::auth(
"--backend-resource-endpoint requires exactly one trusted Server identity"
"--backend-resource-endpoint requires exactly one trusted Workspace issuer"
.to_owned(),
));
};
@@ -234,7 +231,7 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
endpoint,
config.backend_resource_token.clone(),
)
.with_runtime_request_source(identity, trusted_server.server_id.clone()),
.with_runtime_request_source(identity, workspace_issuer.backend_url.clone()),
);
factory = factory.with_resource_client(client.clone());
backend_resource_client = Some(client);
@@ -254,11 +251,10 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
}
RuntimeHttpStoreSelection::Fs { root } => {
let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id(
config
.http
.auth
runtime_auth
.identity
.as_ref()
.map(|auth| auth.runtime_id.as_str())
.map(|identity| identity.identity_id.as_str())
.unwrap_or("local"),
);
options.display_name = config.http.display_name.clone();
@@ -549,8 +545,6 @@ struct RuntimeAuthFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
identity: Option<RuntimeIdentityMaterial>,
#[serde(default)]
trusted_servers: Vec<TrustedServerKey>,
#[serde(default)]
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
}
@@ -924,23 +918,6 @@ fn load_workspace_runtime_http_auth(
}))
}
fn load_runtime_http_auth(
config: &ProcessConfig,
) -> Result<Option<RuntimeHttpAuthConfig>, ProcessError> {
let path = runtime_auth_path(config);
let auth = read_runtime_auth_file(&path)?;
let Some(identity) = auth.identity else {
return Ok(None);
};
if auth.trusted_servers.is_empty() {
return Ok(None);
}
Ok(Some(RuntimeHttpAuthConfig {
runtime_id: identity.identity_id,
trusted_servers: auth.trusted_servers,
}))
}
fn parse_auth_storage_flags(args: &mut VecDeque<String>) -> Result<ProcessConfig, ProcessError> {
let mut config = ProcessConfig::default()?;
while let Some(arg) = args.pop_front() {
@@ -967,7 +944,6 @@ fn run_auth_command(args: Vec<String>) -> Result<(), ProcessError> {
let command = args.pop_front().unwrap_or_default();
match command.as_str() {
"identity" => run_identity_command(args),
"trust-server" => run_trust_server_command(args),
"trust-workspace" => run_trust_workspace_command(args),
_ => Err(ProcessError::usage(format!(
"unknown auth command `{command}`"
@@ -1096,187 +1072,6 @@ fn run_identity_command(mut args: VecDeque<String>) -> Result<(), ProcessError>
}
}
fn run_trust_server_command(mut args: VecDeque<String>) -> Result<(), ProcessError> {
let subcommand = args.pop_front().ok_or_else(|| {
ProcessError::usage(
"trust-server requires subcommand `add`, `list`, or `revoke`".to_string(),
)
})?;
match subcommand.as_str() {
"add" => {
let mut server_id = None;
let mut public_key = None;
let mut display_name = None;
let mut replace = false;
let mut rest = VecDeque::new();
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
"--public-key" => {
public_key = Some(take_value(&flag, inline_value, &mut args)?)
}
"--display-name" => {
display_name = Some(take_value(&flag, inline_value, &mut args)?)
}
"--replace" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
replace = true;
}
"--fs-root" | "--fs-runtime-dir" => {
rest.push_back(flag);
if let Some(value) = inline_value {
rest.push_back(value);
} else {
rest.push_back(args.pop_front().ok_or_else(|| {
ProcessError::usage(format!(
"{} requires a value",
rest.back().unwrap()
))
})?);
}
}
_ => {
return Err(ProcessError::usage(format!(
"unknown trust-server add argument `{flag}`"
)));
}
}
}
let config = parse_auth_storage_flags(&mut rest)?;
let path = runtime_auth_path(&config);
let mut auth = read_runtime_auth_file(&path)?;
let server_id = server_id.ok_or_else(|| {
ProcessError::usage("trust-server add requires --server-id".to_string())
})?;
let public_key = public_key.ok_or_else(|| {
ProcessError::usage("trust-server add requires --public-key".to_string())
})?;
decode_public_key(&public_key)
.map_err(|error| ProcessError::usage(error.to_string()))?;
if auth
.trusted_servers
.iter()
.any(|server| server.server_id == server_id)
&& !replace
{
return Err(ProcessError::usage(format!(
"trusted server `{server_id}` already exists; pass --replace to update it"
)));
}
auth.trusted_servers
.retain(|server| server.server_id != server_id);
auth.trusted_servers.push(TrustedServerKey {
server_id: server_id.clone(),
public_key,
display_name,
});
write_runtime_auth_file(&path, &auth)?;
println!("trusted_server_id={server_id}");
println!("auth_file={}", path.display());
Ok(())
}
"list" => {
let mut json = false;
let mut rest = VecDeque::new();
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--json" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
json = true;
}
"--fs-root" | "--fs-runtime-dir" => {
rest.push_back(flag);
if let Some(value) = inline_value {
rest.push_back(value);
} else {
rest.push_back(args.pop_front().ok_or_else(|| {
ProcessError::usage(format!(
"{} requires a value",
rest.back().unwrap()
))
})?);
}
}
_ => {
return Err(ProcessError::usage(format!(
"unknown trust-server list argument `{flag}`"
)));
}
}
}
let config = parse_auth_storage_flags(&mut rest)?;
let auth = read_runtime_auth_file(&runtime_auth_path(&config))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&auth.trusted_servers)
.map_err(|error| ProcessError::auth(error.to_string()))?
);
} else {
for server in auth.trusted_servers {
println!(
"server_id={} public_key={} display_name={}",
server.server_id,
server.public_key,
server.display_name.unwrap_or_default()
);
}
}
Ok(())
}
"revoke" => {
let mut server_id = None;
let mut rest = VecDeque::new();
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
"--fs-root" | "--fs-runtime-dir" => {
rest.push_back(flag);
if let Some(value) = inline_value {
rest.push_back(value);
} else {
rest.push_back(args.pop_front().ok_or_else(|| {
ProcessError::usage(format!(
"{} requires a value",
rest.back().unwrap()
))
})?);
}
}
_ => {
return Err(ProcessError::usage(format!(
"unknown trust-server revoke argument `{flag}`"
)));
}
}
}
let config = parse_auth_storage_flags(&mut rest)?;
let path = runtime_auth_path(&config);
let mut auth = read_runtime_auth_file(&path)?;
let server_id = server_id.ok_or_else(|| {
ProcessError::usage("trust-server revoke requires --server-id".to_string())
})?;
let before = auth.trusted_servers.len();
auth.trusted_servers
.retain(|server| server.server_id != server_id);
if auth.trusted_servers.len() == before {
return Err(ProcessError::usage(format!(
"trusted server `{server_id}` is not registered"
)));
}
write_runtime_auth_file(&path, &auth)?;
println!("revoked_server_id={server_id}");
Ok(())
}
_ => Err(ProcessError::usage(format!(
"unknown trust-server subcommand `{subcommand}`"
))),
}
}
fn usage() -> &'static str {
r#"Usage: yoi-runtime [OPTIONS]
yoi-runtime migrate --dry-run [--runtime-id <ID>] [OPTIONS]
@@ -1302,14 +1097,11 @@ Options:
Auth commands:
identity init --runtime-id ID [--replace] [--fs-root PATH] [--fs-runtime-dir PATH]
identity show [--json] [--fs-root PATH] [--fs-runtime-dir PATH]
trust-server add --server-id ID --public-key KEY [--display-name NAME] [--replace] [--fs-root PATH] [--fs-runtime-dir PATH]
trust-server list [--json] [--fs-root PATH] [--fs-runtime-dir PATH]
trust-workspace add --bundle PATH [--fs-root PATH] [--fs-runtime-dir PATH]
trust-workspace list [--offset N] [--limit N] [--fs-root PATH] [--fs-runtime-dir PATH]
trust-workspace show --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH]
trust-workspace replace --bundle PATH [--fs-root PATH] [--fs-runtime-dir PATH]
trust-workspace revoke --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH]
trust-server revoke --server-id ID [--fs-root PATH] [--fs-runtime-dir PATH]"#
trust-workspace revoke --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH]"#
}
#[cfg(test)]
@@ -1700,6 +1492,35 @@ mod tests {
);
}
#[test]
fn legacy_server_trust_entries_are_dropped_when_auth_store_is_rewritten() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("runtime-auth.toml");
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
std::fs::write(
&path,
format!(
"identity = {{ identity_id = \"{}\", private_key = \"{}\", public_key = \"{}\" }}\n[[trusted_servers]]\nserver_id = \"removed\"\npublic_key = \"removed\"\n",
identity.identity_id, identity.private_key, identity.public_key
),
)
.unwrap();
let auth = read_runtime_auth_file(&path).unwrap();
write_runtime_auth_file(&path, &auth).unwrap();
let rewritten = std::fs::read_to_string(path).unwrap();
assert!(!rewritten.contains("trusted_servers"));
assert!(!rewritten.contains("server_id"));
assert!(rewritten.contains("identity_id = \"runtime-a\""));
}
#[test]
fn removed_server_trust_command_is_rejected() {
let error = run_auth_command(vec!["trust-server".to_owned(), "list".to_owned()])
.unwrap_err()
.to_string();
assert_eq!(error, "unknown auth command `trust-server`");
}
#[test]
fn no_store_disables_runtime_catalog_persistence() {
let config = parse_args(["--no-store"]).unwrap().unwrap();
+1 -111
View File
@@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use workspace_api::WorkspacePublicIdentityBundle;
use crate::auth::{RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig};
use crate::auth::RuntimeAuthError;
const WORKSPACE_TOKEN_PREFIX: &str = "yoi-workspace-v1";
const WORKSPACE_SIGNING_INPUT_PREFIX: &str = "yoi.workspace.capability.v1.";
@@ -1408,68 +1408,6 @@ pub(crate) fn hex_lower(bytes: &[u8]) -> String {
output
}
pub enum RuntimeCapabilityVerifier {
LegacyServer(RuntimeHttpAuthConfig),
WorkspaceIssuer(WorkspaceCapabilityVerifier),
}
pub enum RuntimeCapabilityVerification<'a> {
LegacyServer {
required_permission: Option<&'a str>,
now_seconds: u64,
},
WorkspaceIssuer(WorkspaceCapabilityExpectation<'a>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VerifiedRuntimeCapability {
LegacyServer(RuntimeAuthContext),
WorkspaceIssuer(VerifiedWorkspaceCapability),
}
#[derive(Debug, thiserror::Error)]
pub enum RuntimeCapabilityVerificationError {
#[error("Runtime capability verifier mode does not match the verification request")]
ModeMismatch,
#[error(transparent)]
LegacyServer(#[from] RuntimeAuthError),
#[error(transparent)]
WorkspaceIssuer(#[from] WorkspaceCapabilityVerificationError),
}
impl RuntimeCapabilityVerifier {
pub fn verify(
&self,
token: &str,
verification: RuntimeCapabilityVerification<'_>,
) -> Result<VerifiedRuntimeCapability, RuntimeCapabilityVerificationError> {
match (self, verification) {
(
Self::LegacyServer(config),
RuntimeCapabilityVerification::LegacyServer {
required_permission,
now_seconds,
},
) => crate::auth::verify_capability_token(
config,
token,
required_permission,
now_seconds,
)
.map(VerifiedRuntimeCapability::LegacyServer)
.map_err(Into::into),
(
Self::WorkspaceIssuer(verifier),
RuntimeCapabilityVerification::WorkspaceIssuer(expectation),
) => verifier
.verify(token, &expectation)
.map(VerifiedRuntimeCapability::WorkspaceIssuer)
.map_err(Into::into),
_ => Err(RuntimeCapabilityVerificationError::ModeMismatch),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1762,54 +1700,6 @@ mod tests {
);
}
#[test]
fn verifier_selection_never_falls_back_between_legacy_and_workspace_authority() {
assert_eq!(
WorkspaceCapabilityVerifier::new(
Vec::new(),
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap_err(),
WorkspaceCapabilityVerificationError::TrustAuthorityMissing
);
let (key, bundle) = identity("workspace-1", "WK-1", 1);
let record = WorkspaceIssuerTrustRecord::from_bundle(bundle, 1, 1).unwrap();
let workspace = RuntimeCapabilityVerifier::WorkspaceIssuer(
WorkspaceCapabilityVerifier::new(
vec![record.clone()],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
);
let token = issue_workspace_capability_token(&key, &claims(&record, "typed-mode")).unwrap();
assert!(matches!(
workspace.verify(
&token,
RuntimeCapabilityVerification::LegacyServer {
required_permission: None,
now_seconds: 1_001,
},
),
Err(RuntimeCapabilityVerificationError::ModeMismatch)
));
assert!(matches!(
RuntimeCapabilityVerifier::WorkspaceIssuer(
WorkspaceCapabilityVerifier::new(
vec![record],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
)
.verify(
&token,
RuntimeCapabilityVerification::WorkspaceIssuer(expectation(
&workspace_request_body_digest(br#"{"content":"hello"}"#),
)),
),
Ok(VerifiedRuntimeCapability::WorkspaceIssuer(_))
));
}
#[test]
fn file_replay_protection_survives_reconstruction() {
let temporary = tempfile::tempdir().unwrap();
-29
View File
@@ -1589,14 +1589,6 @@ pub enum WorkspaceRuntimeBindingState {
Revoked,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceRuntimeAuthenticationMode {
LegacyServerIssuer,
WorkspaceIdentity,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
@@ -1642,7 +1634,6 @@ pub struct RuntimeVerificationEvidenceSummary {
pub struct WorkspaceRuntimeBindingSummary {
pub state: WorkspaceRuntimeBindingState,
pub connection_state: RuntimeConnectionDisplayState,
pub authentication_mode: WorkspaceRuntimeAuthenticationMode,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1747,16 +1738,6 @@ pub struct RuntimeTrustKeyRevealResponse {
pub public_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PutRuntimeTrustKeyRequest {
pub public_key: String,
#[serde(default)]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -3026,7 +3007,6 @@ pub fn catalog_typescript() -> String {
RuntimeConnectionDisplayState::decl(&config),
RuntimeVerificationOutcome::decl(&config),
RuntimeVerificationEvidenceSummary::decl(&config),
WorkspaceRuntimeAuthenticationMode::decl(&config),
WorkspaceRuntimeBindingSummary::decl(&config),
RuntimeManagementSummary::decl(&config),
WorkspaceRuntimeResource::decl(&config),
@@ -3036,7 +3016,6 @@ pub fn catalog_typescript() -> String {
RuntimeTrustAuditEntry::decl(&config),
WorkspaceRuntimeDetail::decl(&config),
RuntimeTrustKeyRevealResponse::decl(&config),
PutRuntimeTrustKeyRequest::decl(&config),
RevokeRuntimeTrustKeyRequest::decl(&config),
RuntimeTrustConflictKind::decl(&config),
RuntimeTrustConflictResponse::decl(&config),
@@ -3869,14 +3848,6 @@ mod tests {
}))
.is_err()
);
assert!(
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
"public_key": "key",
"expected_revision": 1,
"replace": true
}))
.is_err()
);
assert!(
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
"expected_revision": 1,
+3 -101
View File
@@ -29,7 +29,6 @@ use workdir::{
http::{OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirHttpAuthorization},
};
use worker_runtime::RuntimeWorkspaceScope;
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource,
RepositoryRefObservation, RepositoryRefObservationRequest,
@@ -2927,7 +2926,6 @@ pub struct RemoteRuntimeConfig {
pub display_name: String,
pub base_url: String,
pub bearer_token: Option<String>,
pub auth: Option<RemoteRuntimeAuthConfig>,
pub workspace_authorization: Option<WorkspaceRuntimeAuthorization>,
pub strict_public_egress: bool,
pub cached_worker_creation_available: bool,
@@ -3099,12 +3097,6 @@ impl WorkspaceRuntimeAuthorization {
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteRuntimeAuthConfig {
pub server_id: String,
pub server_private_key: String,
}
impl std::fmt::Debug for RemoteRuntimeConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RemoteRuntimeConfig")
@@ -3115,7 +3107,6 @@ impl std::fmt::Debug for RemoteRuntimeConfig {
"bearer_token",
&self.bearer_token.as_ref().map(|_| "<redacted>"),
)
.field("auth", &self.auth.as_ref().map(|_| "<capability-signer>"))
.field("strict_public_egress", &self.strict_public_egress)
.field(
"cached_worker_creation_available",
@@ -3142,7 +3133,6 @@ impl RemoteRuntimeConfig {
display_name: display_name.into(),
base_url: base_url.into(),
bearer_token,
auth: None,
workspace_authorization: None,
strict_public_egress: false,
cached_worker_creation_available: false,
@@ -3158,11 +3148,6 @@ impl RemoteRuntimeConfig {
self
}
pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self {
self.auth = Some(auth);
self
}
pub fn with_strict_public_egress(mut self, strict: bool) -> Self {
self.strict_public_egress = strict;
self
@@ -3181,9 +3166,6 @@ impl RemoteRuntimeConfig {
#[derive(Clone)]
struct RemoteWorkdirAuthorization {
runtime_id: String,
workspace_id: String,
auth: Option<RemoteRuntimeAuthConfig>,
workspace_authorization: Option<WorkspaceRuntimeAuthorization>,
fallback_bearer_token: Option<String>,
}
@@ -3192,9 +3174,6 @@ impl std::fmt::Debug for RemoteWorkdirAuthorization {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RemoteWorkdirAuthorization")
.field("runtime_id", &self.runtime_id)
.field("workspace_id", &self.workspace_id)
.field("auth", &self.auth.as_ref().map(|_| "capability_token"))
.field(
"fallback_bearer_token",
&self.fallback_bearer_token.as_ref().map(|_| "configured"),
@@ -3221,19 +3200,6 @@ impl WorkdirHttpAuthorization for RemoteWorkdirAuthorization {
)
.map_err(|error| WorkdirError::Unavailable(error.message));
}
if let Some(auth) = self.auth.as_ref() {
let claims = capability_claims(
&auth.server_id,
&self.runtime_id,
&self.workspace_id,
all_remote_runtime_permissions(),
300,
)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
return CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key)
.sign(&claims)
.map_err(|error| WorkdirError::Unavailable(error.to_string()));
}
self.fallback_bearer_token.clone().ok_or_else(|| {
WorkdirError::Unavailable(
"remote Runtime does not have bearer authorization configured".to_string(),
@@ -3350,7 +3316,6 @@ pub struct RemoteWorkerRuntime {
base_url: String,
workspace_id: String,
bearer_token: Option<String>,
auth: Option<RemoteRuntimeAuthConfig>,
workspace_authorization: Option<WorkspaceRuntimeAuthorization>,
cached_worker_creation_available: bool,
cached_os: String,
@@ -3467,22 +3432,6 @@ fn worker_id_from_remote_path(path_and_query: &str) -> Option<String> {
(!worker_id.is_empty()).then(|| worker_id.to_string())
}
fn all_remote_runtime_permissions() -> Vec<String> {
[
"workers:list",
"workers:create",
"workers:read",
"workers:delete",
"workers:input",
"workers:stop",
"workers:protocol",
"workdirs:operate",
]
.into_iter()
.map(str::to_string)
.collect()
}
impl RemoteWorkerRuntime {
pub fn new(
config: RemoteRuntimeConfig,
@@ -3546,7 +3495,6 @@ impl RemoteWorkerRuntime {
base_url,
workspace_id,
bearer_token: config.bearer_token,
auth: config.auth,
workspace_authorization: config.workspace_authorization,
cached_worker_creation_available: config.cached_worker_creation_available,
cached_os: config.cached_os,
@@ -3573,9 +3521,6 @@ impl RemoteWorkerRuntime {
let workdir_id = Workdir::new(working_directory_id).id().clone();
let authorization: Arc<dyn WorkdirHttpAuthorization> =
Arc::new(RemoteWorkdirAuthorization {
runtime_id: self.runtime_id.clone(),
workspace_id: self.workspace_id.clone(),
auth: self.auth.clone(),
workspace_authorization: self.workspace_authorization.clone(),
fallback_bearer_token: self.bearer_token.clone(),
});
@@ -3708,44 +3653,6 @@ impl RemoteWorkerRuntime {
self.send_json(path, "DELETE", &[], self.http.delete(self.endpoint(path)))
}
fn runtime_capability_token_with_permissions(
&self,
path: &str,
permissions: Vec<String>,
) -> Option<String> {
let auth = self.auth.as_ref()?;
let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key);
let claims = capability_claims(
&auth.server_id,
&self.runtime_id,
&self.workspace_id,
permissions,
300,
)
.map_err(|error| {
eprintln!(
"failed to build Runtime capability claims for {} {}: {error}",
self.runtime_id, path
);
error
})
.ok()?;
signer
.sign(&claims)
.map_err(|error| {
eprintln!(
"failed to sign Runtime capability token for {} {}: {error}",
self.runtime_id, path
);
error
})
.ok()
}
fn runtime_capability_token(&self, path: &str) -> Option<String> {
self.runtime_capability_token_with_permissions(path, all_remote_runtime_permissions())
}
fn ping_http(&self) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
const PATH: &str = "/v1/ping";
let workspace_id = self.workspace_id.clone();
@@ -3762,10 +3669,7 @@ impl RemoteWorkerRuntime {
)
})?,
),
None => self.runtime_capability_token_with_permissions(
PATH,
vec![RUNTIME_PING_PERMISSION.to_string()],
),
None => None,
};
let request = self
.http
@@ -3852,7 +3756,7 @@ impl RemoteWorkerRuntime {
worker_id_from_remote_path(path).as_deref(),
body,
)?),
None => self.runtime_capability_token(path),
None => None,
};
run_blocking_http(move || {
let request = request
@@ -4560,9 +4464,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
Some(authorization) => authorization
.issue("GET", &path, "workers:protocol", Some(worker_id), &[])
.ok(),
None => self
.runtime_capability_token(&path)
.or_else(|| self.bearer_token.clone()),
None => self.bearer_token.clone(),
};
Some(crate::observation::RuntimeObservationSource::remote_ws(
crate::observation::RuntimeObservationSourceConfig {
+59 -451
View File
@@ -1,14 +1,11 @@
use std::collections::VecDeque;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::hosts::{EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig};
use yoi_workspace_server::store::{
SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
WorkspaceRuntimeBindingState,
@@ -21,8 +18,6 @@ use yoi_workspace_server::{
#[derive(Debug)]
enum Command {
Serve(ServeOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
Migrate(MigrateOptions),
Skills(SkillsCommand),
Help,
@@ -79,8 +74,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await,
Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::Migrate(options) => run_migrate(options),
Command::Skills(command) => run_skills(command),
Command::Help => Ok(()),
@@ -94,8 +87,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
};
match command.as_str() {
"identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_options(rest).map(Command::Migrate),
"skills" => parse_skills_command(rest),
"serve" => {
@@ -110,375 +101,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help)
}
other => Err(CliError(format!(
"unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
"unknown command `{other}`; expected `migrate`, `skills`, or `serve`"
))),
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct ServerIdentityFile {
identity: RuntimeIdentityMaterial,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct PublicIdentityView {
identity_id: String,
public_key: String,
}
fn server_identity_path() -> PathBuf {
ServerConfig::default_server_data_root().join("identity.toml")
}
fn read_server_identity_file(
path: &Path,
) -> Result<Option<ServerIdentityFile>, Box<dyn std::error::Error>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)?;
Ok(Some(toml::from_str(&contents)?))
}
fn write_server_identity_file(
path: &Path,
identity: &ServerIdentityFile,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(identity)?;
write_secret_file(path, contents.as_bytes())?;
Ok(())
}
fn write_secret_file(path: &Path, contents: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)?;
use std::io::Write as _;
file.write_all(contents)?;
}
#[cfg(not(unix))]
{
std::fs::write(path, contents)?;
}
Ok(())
}
fn public_identity_view(identity: &RuntimeIdentityMaterial) -> PublicIdentityView {
PublicIdentityView {
identity_id: identity.identity_id.clone(),
public_key: identity.public_key.clone(),
}
}
fn run_identity_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut args = VecDeque::from(args);
let subcommand = args
.pop_front()
.ok_or_else(|| CliError("identity requires `init` or `show`".to_string()))?;
match subcommand.as_str() {
"init" => {
let mut server_id = None;
let mut replace = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
"--replace" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
replace = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown identity init argument `{flag}`"
))));
}
}
}
let server_id = server_id
.ok_or_else(|| CliError("identity init requires --server-id".to_string()))?;
let path = server_identity_path();
if read_server_identity_file(&path)?.is_some() && !replace {
return Err(Box::new(CliError(format!(
"server identity already exists at {}; pass --replace to rotate it",
path.display()
))));
}
let identity = RuntimeIdentityMaterial::generate(server_id)?;
write_server_identity_file(
&path,
&ServerIdentityFile {
identity: identity.clone(),
},
)?;
println!("server_id={}", identity.identity_id);
println!("public_key={}", identity.public_key);
println!("identity_file={}", path.display());
Ok(())
}
"show" => {
let mut json = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--json" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
json = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown identity show argument `{flag}`"
))));
}
}
}
let path = server_identity_path();
let identity = read_server_identity_file(&path)?.ok_or_else(|| {
CliError(format!(
"server identity is not initialized at {}",
path.display()
))
})?;
let view = public_identity_view(&identity.identity);
if json {
println!("{}", serde_json::to_string_pretty(&view)?);
} else {
println!("server_id={}", view.identity_id);
println!("public_key={}", view.public_key);
println!("identity_file={}", path.display());
}
Ok(())
}
_ => Err(Box::new(CliError(format!(
"unknown identity subcommand `{subcommand}`"
)))),
}
}
fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut args = VecDeque::from(args);
let subcommand = args
.pop_front()
.ok_or_else(|| CliError("trust-runtime requires `add`, `list`, or `revoke`".to_string()))?;
let database_path = ServerConfig::default_server_database_path();
if let Some(parent) = database_path.parent() {
std::fs::create_dir_all(parent)?;
}
let store = SqliteWorkspaceStore::open(&database_path)?;
match subcommand.as_str() {
"add" => {
let mut runtime_id = None;
let mut workspace_id = None;
let mut base_url = None;
let mut public_key = None;
let mut display_name = None;
let mut replace = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--runtime-id" => {
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--base-url" | "--endpoint" => {
base_url = Some(take_value(&flag, inline_value, &mut args)?)
}
"--public-key" => {
public_key = Some(take_value(&flag, inline_value, &mut args)?)
}
"--display-name" => {
display_name = Some(take_value(&flag, inline_value, &mut args)?)
}
"--replace" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
replace = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime add argument `{flag}`"
))));
}
}
}
let runtime_id = runtime_id
.ok_or_else(|| CliError("trust-runtime add requires --runtime-id".to_string()))?;
let workspace_id = workspace_id
.ok_or_else(|| CliError("trust-runtime add requires --workspace-id".to_string()))?;
if !store
.list_workspaces()?
.iter()
.any(|workspace| workspace.workspace_id == workspace_id)
{
return Err(Box::new(CliError(format!(
"Workspace `{workspace_id}` is not registered"
))));
}
let base_url = base_url
.ok_or_else(|| CliError("trust-runtime add requires --base-url".to_string()))?;
let public_key = public_key
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
decode_public_key(&public_key)?;
let now = Utc::now().to_rfc3339();
let outcome = store.upsert_workspace_runtime_binding(
WorkspaceRuntimeBinding {
workspace_id: workspace_id.clone(),
runtime_id: runtime_id.clone(),
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
base_url,
public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: now.clone(),
updated_at: now,
revoked_at: None,
},
replace,
)?;
println!("workspace_id={workspace_id}");
println!("runtime_id={runtime_id}");
println!(
"result={}",
match outcome {
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Created =>
"created",
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged =>
"unchanged",
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Replaced =>
"replaced",
}
);
println!("server_db={}", database_path.display());
Ok(())
}
"list" => {
let mut workspace_id = None;
let mut json = false;
let mut include_revoked = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--json" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
json = true;
}
"--include-revoked" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
include_revoked = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime list argument `{flag}`"
))));
}
}
}
let workspace_id = workspace_id.ok_or_else(|| {
CliError("trust-runtime list requires --workspace-id".to_string())
})?;
let records = store.list_workspace_runtime_bindings(&workspace_id, include_revoked)?;
if json {
println!("{}", serde_json::to_string_pretty(&records)?);
} else {
for runtime in records {
println!(
"workspace_id={} runtime_id={} base_url={} public_key_fingerprint={} revoked_at={}",
runtime.workspace_id,
runtime.runtime_id,
runtime.base_url,
runtime.public_key_fingerprint,
runtime.revoked_at.unwrap_or_default()
);
}
}
Ok(())
}
"revoke" => {
let mut workspace_id = None;
let mut runtime_id = None;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--runtime-id" => {
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime revoke argument `{flag}`"
))));
}
}
}
let workspace_id = workspace_id.ok_or_else(|| {
CliError("trust-runtime revoke requires --workspace-id".to_string())
})?;
let runtime_id = runtime_id.ok_or_else(|| {
CliError("trust-runtime revoke requires --runtime-id".to_string())
})?;
let now = Utc::now().to_rfc3339();
if !store.revoke_workspace_runtime_binding(&workspace_id, &runtime_id, &now)? {
return Err(Box::new(CliError(format!(
"trusted runtime `{runtime_id}` is not registered or is already revoked"
))));
}
println!("revoked_runtime_id={runtime_id}");
Ok(())
}
_ => Err(Box::new(CliError(format!(
"unknown trust-runtime subcommand `{subcommand}`"
)))),
}
}
fn split_flag_value(arg: String) -> Result<(String, Option<String>), CliError> {
if let Some((flag, value)) = arg.split_once('=') {
if flag.is_empty() {
return Err(CliError("empty flag name".to_string()));
}
Ok((flag.to_string(), Some(value.to_string())))
} else {
Ok((arg, None))
}
}
fn take_value(
flag: &str,
inline_value: Option<String>,
args: &mut VecDeque<String>,
) -> Result<String, CliError> {
if let Some(value) = inline_value {
return Ok(value);
}
args.pop_front()
.ok_or_else(|| CliError(format!("{flag} requires a value")))
}
fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(), CliError> {
if inline_value.is_some() {
return Err(CliError(format!("{flag} does not accept a value")));
}
Ok(())
}
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
match command {
SkillsCommand::List(options) => {
@@ -526,6 +153,30 @@ fn load_skill_workspace_config(
})
}
fn remote_runtime_config_from_binding(
binding: WorkspaceRuntimeBinding,
) -> Result<Option<RemoteRuntimeConfig>, CliError> {
if binding.runtime_id == EMBEDDED_RUNTIME_ID {
return Ok(None);
}
if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity {
return Err(CliError(format!(
"Runtime binding '{}:{}' still uses removed legacy Server-issued authentication",
binding.workspace_id, binding.runtime_id
)));
}
Ok(Some(
RemoteRuntimeConfig::new(
binding.runtime_id,
binding.display_name,
binding.base_url,
None,
)
.with_workspace_id(binding.workspace_id)
.with_strict_public_egress(true),
))
}
fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>> {
if options.help {
print_migrate_help();
@@ -655,33 +306,13 @@ fn append_workspace_runtime_sources(
.into_iter()
.flatten()
.collect::<Vec<_>>();
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else {
if !bindings.is_empty() {
return Err(Box::new(CliError(
"Runtime bindings are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
)));
}
return Ok(());
};
for runtime in bindings {
let auth = RemoteRuntimeAuthConfig {
server_id: server_identity.identity.identity_id.clone(),
server_private_key: server_identity.identity.private_key.clone(),
for binding in bindings {
let Some(remote) = remote_runtime_config_from_binding(binding)? else {
continue;
};
let remote = RemoteRuntimeConfig::new(
runtime.runtime_id.clone(),
runtime.display_name,
runtime.base_url,
None,
)
.with_workspace_id(runtime.workspace_id.clone())
.with_auth(auth)
.with_strict_public_egress(
runtime.authentication_mode == WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
);
remote_runtime_sources.retain(|existing| {
existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str())
|| existing.runtime_id != runtime.runtime_id
existing.workspace_id.as_deref() != remote.workspace_id.as_deref()
|| existing.runtime_id != remote.runtime_id
});
remote_runtime_sources.push(remote);
}
@@ -851,7 +482,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list --workspace-id <WORKSPACE_ID> [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id <WORKSPACE_ID> --runtime-id <RUNTIME_ID>\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
"yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
@@ -963,64 +594,41 @@ mod tests {
"unknown serve option `--frontend=/tmp/web`"
);
}
#[test]
fn server_identity_init_requires_explicit_server_id() {
let error = run_identity_command(vec!["init".to_string()]).unwrap_err();
assert_eq!(error.to_string(), "identity init requires --server-id");
}
#[test]
fn runtime_binding_requires_explicit_replace_for_changed_authority() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
let store = SqliteWorkspaceStore::open(&path).unwrap();
rusqlite::Connection::open(&path)
.unwrap()
.execute_batch(
"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
VALUES ('owner', 'user', 'owner', 'Owner', '1', '1');
INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at)
VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1');",
)
.unwrap();
let public_key = RuntimeIdentityMaterial::generate("runtime-a")
.unwrap()
.public_key;
fn runtime_startup_rejects_legacy_server_issuer_bindings() {
let binding = WorkspaceRuntimeBinding {
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-a".to_string(),
display_name: "Runtime A".to_string(),
base_url: "http://127.0.0.1:18080".to_string(),
public_key,
public_key_fingerprint: String::new(),
workspace_id: "workspace-a".to_owned(),
runtime_id: "runtime-a".to_owned(),
display_name: "Runtime A".to_owned(),
base_url: "https://runtime.example.test".to_owned(),
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "2026-07-26T00:00:00Z".to_string(),
updated_at: "2026-07-26T00:00:00Z".to_string(),
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
};
store
.upsert_workspace_runtime_binding(binding.clone(), false)
.unwrap();
assert!(matches!(
store
.upsert_workspace_runtime_binding(binding.clone(), false)
.unwrap(),
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged
));
let mut changed = binding;
changed.base_url = "http://127.0.0.1:18081".to_string();
assert!(
store
.upsert_workspace_runtime_binding(changed.clone(), false)
.is_err()
let error = remote_runtime_config_from_binding(binding)
.unwrap_err()
.to_string();
assert_eq!(
error,
"Runtime binding 'workspace-a:runtime-a' still uses removed legacy Server-issued authentication"
);
store
.upsert_workspace_runtime_binding(changed, true)
.unwrap();
}
#[test]
fn parse_cli_rejects_removed_server_global_runtime_trust_commands() {
for command in ["identity", "trust-runtime"] {
let error = parse_command(&[command.to_owned()]).unwrap_err();
assert_eq!(
error.to_string(),
format!("unknown command `{command}`; expected `migrate`, `skills`, or `serve`")
);
}
}
}
@@ -13,7 +13,6 @@ use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::{client_async_tls_with_config, connect_async};
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use crate::hosts::{RemoteRuntimeConfig, resolve_strict_remote_runtime_endpoint};
@@ -968,7 +967,7 @@ fn runtime_endpoint(base_url: &str) -> String {
}
fn runtime_token(
config: &RemoteRuntimeConfig,
workspace_id: &str,
_workspace_id: &str,
) -> Result<Option<String>, String> {
if let Some(authorization) = config.workspace_authorization.as_ref() {
return authorization
@@ -976,22 +975,7 @@ fn runtime_token(
.map(Some)
.map_err(|error| error.message);
}
let Some(auth) = config.auth.as_ref() else {
return Ok(config.bearer_token.clone());
};
let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key);
let claims = capability_claims(
&auth.server_id,
&config.runtime_id,
workspace_id,
vec!["workers:list".into()],
300,
)
.map_err(|error| error.to_string())?;
signer
.sign(&claims)
.map(Some)
.map_err(|error| error.to_string())
Ok(config.bearer_token.clone())
}
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
+49 -310
View File
@@ -82,9 +82,9 @@ use workspace_api::{
PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest,
PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
PutRuntimeTrustKeyRequest, RepositoryAccessProjection, RepositoryDetailResponse,
RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest,
RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse,
RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor,
RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest,
RuntimeConnectionDisplayState, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse,
RuntimeConnectionTestStatus, RuntimeManagementSummary, RuntimeTrustAuditAction,
RuntimeTrustAuditEntry, RuntimeTrustConflictKind, RuntimeTrustConflictResponse,
@@ -103,9 +103,9 @@ use workspace_api::{
WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionState,
WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse,
WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspacePublicIdentityBundle,
WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeAuthenticationMode,
WorkspaceRuntimeBindingState, WorkspaceRuntimeBindingSummary, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource, WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse,
WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeBindingState,
WorkspaceRuntimeBindingSummary, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse,
WorkspaceSigningIdentityState, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
};
@@ -3375,9 +3375,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
get(scoped_reveal_runtime_trust_key)
.put(scoped_put_runtime_trust_key)
.delete(scoped_revoke_runtime_trust_key),
get(scoped_reveal_runtime_trust_key).delete(scoped_revoke_runtime_trust_key),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests",
@@ -11828,7 +11826,7 @@ async fn scoped_reveal_runtime_trust_key(
if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
return Err(settings_bad_request(
"embedded_runtime_trust_managed_internally",
"the embedded Runtime trust key is managed by Server identity authority",
"the embedded Runtime trust key is managed by the embedded Runtime authority",
));
}
let binding = api
@@ -11843,144 +11841,6 @@ async fn scoped_reveal_runtime_trust_key(
}))
}
async fn scoped_put_runtime_trust_key(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>,
Extension(actor): Extension<RequestActor>,
Json(request): Json<PutRuntimeTrustKeyRequest>,
) -> std::result::Result<Response, ApiError> {
validate_workspace_scope(&api, &path.workspace_id)?;
require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime trust changes").await?;
let actor_account_id = actor.account_id.clone();
if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
return Err(settings_bad_request(
"embedded_runtime_trust_managed_internally",
"the embedded Runtime trust key is managed by Server identity authority",
));
}
if request.expected_revision == Some(0) {
return Err(settings_bad_request(
"invalid_runtime_binding_revision",
"expected_revision must be greater than zero when provided",
));
}
if request.public_key.len() > 16 * 1024 {
return Err(settings_bad_request(
"runtime_public_key_too_large",
"public_key must be at most 16384 bytes",
));
}
let existing = api
.store
.get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id)
.await?;
if existing.as_ref().is_some_and(|binding| {
binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity
}) {
return Err(settings_bad_request(
"workspace_identity_runtime_key_managed_by_binding",
"replace a Workspace identity Runtime public bundle through the Runtime registration operation with expected_revision",
));
}
let source = api
.config
.remote_runtime_sources
.iter()
.find(|source| {
source.runtime_id == path.runtime_id
&& source.workspace_id.as_deref() == Some(path.workspace_id.as_str())
})
.cloned();
if let (Some(binding), Some(source)) = (&existing, &source)
&& binding.base_url != source.base_url
{
return Err(settings_bad_request(
"runtime_endpoint_mismatch",
"the persisted Runtime endpoint no longer matches Server Runtime configuration; reconcile the endpoint before changing trust",
));
}
let (display_name, base_url) = if let Some(binding) = &existing {
(binding.display_name.clone(), binding.base_url.clone())
} else if let Some(source) = &source {
(source.display_name.clone(), source.base_url.clone())
} else {
return Err(Error::UnknownRuntime(path.runtime_id.clone()).into());
};
let now = Utc::now().to_rfc3339();
let record = WorkspaceRuntimeBinding {
workspace_id: path.workspace_id.clone(),
runtime_id: path.runtime_id.clone(),
display_name,
base_url,
public_key: request.public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: existing.as_ref().map_or(
StoredRuntimeBindingState::Verified,
|binding| match binding.authentication_mode {
StoredRuntimeAuthenticationMode::LegacyServerIssuer => {
StoredRuntimeBindingState::Verified
}
StoredRuntimeAuthenticationMode::WorkspaceIdentity => {
StoredRuntimeBindingState::Configured
}
},
),
authentication_mode: existing.as_ref().map_or(
StoredRuntimeAuthenticationMode::LegacyServerIssuer,
|binding| binding.authentication_mode,
),
workspace_key_id: existing
.as_ref()
.and_then(|binding| binding.workspace_key_id.clone()),
workspace_key_generation: existing
.as_ref()
.and_then(|binding| binding.workspace_key_generation),
created_at: existing
.as_ref()
.map_or_else(|| now.clone(), |binding| binding.created_at.clone()),
updated_at: now,
revoked_at: None,
};
let mutation = api
.store
.put_workspace_runtime_binding_key(record, request.expected_revision, &actor_account_id)
.await;
let (_, binding) = match mutation {
Ok(result) => result,
Err(error) => {
if let Some(response) = runtime_trust_conflict_response(&api, &path, &error).await {
return Ok(response);
}
return Err(error.into());
}
};
{
let mut expectations = api
.runtime_binding_expectations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if binding.state == StoredRuntimeBindingState::Verified {
expectations.insert(
(path.workspace_id.clone(), path.runtime_id.clone()),
binding.clone(),
);
} else {
expectations.remove(&(path.workspace_id.clone(), path.runtime_id.clone()));
}
}
if binding.state == StoredRuntimeBindingState::Verified
&& let Some(source) = source
{
api.runtime_subscription_broker
.register_remote_runtime(source);
}
Ok(
Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?)
.into_response(),
)
}
async fn scoped_revoke_runtime_trust_key(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>,
@@ -11993,7 +11853,7 @@ async fn scoped_revoke_runtime_trust_key(
if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
return Err(settings_bad_request(
"embedded_runtime_trust_managed_internally",
"the embedded Runtime trust key is managed by Server identity authority",
"the embedded Runtime trust key is managed by the embedded Runtime authority",
));
}
if request.expected_revision == 0 {
@@ -16284,14 +16144,6 @@ fn runtime_binding_summary(
StoredRuntimeBindingState::Revoked => WorkspaceRuntimeBindingState::Revoked,
},
connection_state,
authentication_mode: match binding.authentication_mode {
StoredRuntimeAuthenticationMode::LegacyServerIssuer => {
WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer
}
StoredRuntimeAuthenticationMode::WorkspaceIdentity => {
WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
}
},
revision: binding.binding_revision,
workspace_key_id: binding.workspace_key_id.clone(),
workspace_key_generation: binding.workspace_key_generation,
@@ -18234,8 +18086,8 @@ mod tests {
use worker_runtime::working_directory::WorkingDirectoryMaterializer;
use crate::hosts::{
RemoteRuntimeAuthConfig, TicketWorkerRole, WorkerInputKind, WorkerOperationState,
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent,
TicketWorkerRole, WorkerInputKind, WorkerOperationState, WorkerSpawnAcceptanceRequirement,
WorkerSpawnIntent,
};
use crate::store::{
AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord,
@@ -18691,16 +18543,13 @@ mod tests {
identity: &worker_runtime::auth::RuntimeIdentityMaterial,
runtime_id: &str,
) {
api.config.backend_base_url = Some("server-test".to_owned());
api.config.remote_runtime_sources.push(RemoteRuntimeConfig {
runtime_id: runtime_id.to_owned(),
workspace_id: Some(api.workspace_id().to_owned()),
display_name: runtime_id.to_owned(),
base_url: "https://runtime.test".to_owned(),
bearer_token: None,
auth: Some(RemoteRuntimeAuthConfig {
server_id: "server-test".to_owned(),
server_private_key: "unused".to_owned(),
}),
workspace_authorization: None,
strict_public_egress: false,
cached_worker_creation_available: true,
@@ -18721,9 +18570,9 @@ mod tests {
public_key_fingerprint: String::new(),
binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-test".to_owned()),
workspace_key_generation: Some(1),
created_at: "2026-01-01T00:00:00Z".to_owned(),
updated_at: "2026-01-01T00:00:00Z".to_owned(),
revoked_at: None,
@@ -20538,10 +20387,6 @@ mod tests {
assert_eq!(status, StatusCode::CREATED);
let binding = created.management.binding.as_ref().unwrap();
assert_eq!(binding.state, WorkspaceRuntimeBindingState::Configured);
assert_eq!(
binding.authentication_mode,
WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
);
assert_eq!(binding.revision, 1);
assert!(binding.workspace_key_id.is_some());
assert_eq!(binding.workspace_key_generation, Some(1));
@@ -20564,25 +20409,6 @@ mod tests {
)),
"configured binding must remain fenced by its current binding revision"
);
let replacement_identity = RuntimeIdentityMaterial::generate("configured-runtime").unwrap();
let generic_put = scoped_put_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: api.config.workspace_id.clone(),
runtime_id: "configured-runtime".to_string(),
}),
Extension(actor.clone()),
Json(PutRuntimeTrustKeyRequest {
public_key: replacement_identity.public_key,
expected_revision: Some(1),
}),
)
.await
.unwrap_err();
assert_eq!(
generic_put.into_response().status(),
StatusCode::BAD_REQUEST
);
let Json(configured_test) = scoped_test_runtime_connection(
State(api.clone()),
AxumPath(ScopedRuntimePath {
@@ -24663,42 +24489,41 @@ mod tests {
}
#[tokio::test]
async fn runtime_trust_management_is_owner_only_revisioned_and_redacted() {
async fn workspace_runtime_trust_reveal_and_revoke_are_owner_only() {
let temp = tempfile::tempdir().unwrap();
let api = test_api(temp.path()).await;
let owner_account_id = format!("account-{TEST_WORKSPACE_ID}");
let owner = RequestActor {
user_id: "owner-user".to_string(),
user_id: "owner-user".to_owned(),
account_id: owner_account_id.clone(),
handle: "owner".to_string(),
display_name: "Owner".to_string(),
handle: "owner".to_owned(),
display_name: "Owner".to_owned(),
auth_method: ActorAuthMethod::BrowserSession,
};
let non_owner = RequestActor {
user_id: "other-user".to_string(),
account_id: "other-account".to_string(),
handle: "other".to_string(),
display_name: "Other".to_string(),
user_id: "other-user".to_owned(),
account_id: "other-account".to_owned(),
handle: "other".to_owned(),
display_name: "Other".to_owned(),
auth_method: ActorAuthMethod::ApiToken,
};
let first = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let second = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let third = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let identity =
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let now = Utc::now().to_rfc3339();
api.store
.put_workspace_runtime_binding_key(
WorkspaceRuntimeBinding {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
display_name: "Runtime A".to_string(),
base_url: "https://runtime.example".to_string(),
public_key: first.public_key,
workspace_id: TEST_WORKSPACE_ID.to_owned(),
runtime_id: "runtime-a".to_owned(),
display_name: "Runtime A".to_owned(),
base_url: "https://runtime.example".to_owned(),
public_key: identity.public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-test".to_owned()),
workspace_key_generation: Some(1),
created_at: now.clone(),
updated_at: now,
revoked_at: None,
@@ -24709,147 +24534,65 @@ mod tests {
.await
.unwrap();
let Json(detail) = scoped_get_runtime_detail(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
}),
)
.await
.unwrap();
assert_eq!(detail.trust_key.revision, Some(1));
assert!(detail.trust_key.fingerprint.is_some());
let Json(revealed) = scoped_reveal_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
workspace_id: TEST_WORKSPACE_ID.to_owned(),
runtime_id: "runtime-a".to_owned(),
}),
Extension(owner.clone()),
)
.await
.unwrap();
assert!(revealed.public_key.starts_with("yoi-ed25519-pub:v1:"));
let denied_reveal = scoped_reveal_runtime_trust_key(
let denied = scoped_reveal_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
workspace_id: TEST_WORKSPACE_ID.to_owned(),
runtime_id: "runtime-a".to_owned(),
}),
Extension(non_owner.clone()),
)
.await
.unwrap_err();
assert_eq!(
denied_reveal.into_response().status(),
StatusCode::FORBIDDEN
);
let response = scoped_put_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
}),
Extension(owner.clone()),
Json(PutRuntimeTrustKeyRequest {
public_key: second.public_key,
expected_revision: Some(1),
}),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let detail: WorkspaceRuntimeDetail = serde_json::from_slice(&body).unwrap();
assert_eq!(detail.trust_key.revision, Some(2));
assert_eq!(
detail.recent_audit[0].action,
RuntimeTrustAuditAction::Replaced
);
let stale = scoped_put_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
}),
Extension(owner.clone()),
Json(PutRuntimeTrustKeyRequest {
public_key: third.public_key,
expected_revision: Some(1),
}),
)
.await
.unwrap();
assert_eq!(stale.status(), StatusCode::CONFLICT);
let body = axum::body::to_bytes(stale.into_body(), usize::MAX)
.await
.unwrap();
let conflict: RuntimeTrustConflictResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(conflict.error, RuntimeTrustConflictKind::StaleRevision);
assert_eq!(conflict.current_revision, Some(2));
assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN);
let denied = scoped_revoke_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
workspace_id: TEST_WORKSPACE_ID.to_owned(),
runtime_id: "runtime-a".to_owned(),
}),
Extension(non_owner),
Json(RevokeRuntimeTrustKeyRequest {
expected_revision: 2,
expected_revision: 1,
}),
)
.await
.unwrap_err();
assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN);
let revoked = scoped_revoke_runtime_trust_key(
let response = scoped_revoke_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
workspace_id: TEST_WORKSPACE_ID.to_owned(),
runtime_id: "runtime-a".to_owned(),
}),
Extension(owner),
Json(RevokeRuntimeTrustKeyRequest {
expected_revision: 2,
expected_revision: 1,
}),
)
.await
.unwrap();
assert_eq!(revoked.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::OK);
let binding = api
.store
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-a")
.await
.unwrap()
.unwrap();
assert_eq!(binding.binding_revision, 3);
assert_eq!(binding.binding_revision, 2);
assert!(binding.revoked_at.is_some());
let listed = workspace_runtime_resources_response(&api, TEST_WORKSPACE_ID)
.await
.unwrap();
let listed_runtime = listed
.items
.iter()
.find(|resource| resource.runtime.runtime_id == "runtime-a")
.expect("revoked binding must remain listed");
assert!(listed_runtime.management.config_managed);
let detail = workspace_runtime_detail(&api, TEST_WORKSPACE_ID, "runtime-a")
.await
.unwrap();
assert_eq!(detail.trust_key.status, RuntimeTrustKeyStatus::Revoked);
assert!(detail.runtime.management.config_managed);
assert!(
!api.runtime_binding_expectations
.read()
.unwrap()
.contains_key(&(TEST_WORKSPACE_ID.to_string(), "runtime-a".to_string()))
);
}
#[tokio::test]
@@ -25350,16 +25093,13 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let identity = RuntimeIdentityMaterial::generate("runtime-remote").unwrap();
let mut config = test_server_config(temp.path());
config.backend_base_url = Some("server-main".to_owned());
config.remote_runtime_sources.push(RemoteRuntimeConfig {
runtime_id: "runtime-remote".to_string(),
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
display_name: "Remote Runtime".to_string(),
base_url: "https://runtime.invalid".to_string(),
bearer_token: None,
auth: Some(RemoteRuntimeAuthConfig {
server_id: "server-main".to_string(),
server_private_key: identity.private_key.clone(),
}),
workspace_authorization: None,
strict_public_egress: false,
cached_worker_creation_available: true,
@@ -26887,7 +26627,6 @@ mod tests {
display_name: "Probe Runtime".to_string(),
base_url: endpoint,
bearer_token: Some("test-connection-token".to_string()),
auth: None,
workspace_authorization: None,
strict_public_egress: false,
cached_worker_creation_available: true,
+13 -15
View File
@@ -10,7 +10,6 @@ use worker_runtime::auth::{
};
use worker_runtime::worker_source::InProcessWorkerMutationProof;
use crate::hosts::RemoteRuntimeConfig;
use crate::server::{ServerConfig, WorkspaceApi};
use crate::store::ControlPlaneStore;
@@ -55,7 +54,7 @@ pub async fn verify_runtime_request_source_proof_with_store(
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
let unverified = decode_runtime_request_source_claims(proof)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, workspace_id)?;
let audience = remote_audience(config, workspace_id)?;
let trusted = store
.get_workspace_runtime_binding(workspace_id, &unverified.iss)
.await
@@ -201,7 +200,7 @@ async fn verify_worker_remove_source_with(
PresentedWorkerMutationSourceProof::Remote(token) => {
let unverified = decode_worker_mutation_source_claims(token)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?;
let audience = remote_audience(config, &config.workspace_id)?;
let trusted = store
.get_workspace_runtime_binding(&config.workspace_id, &unverified.iss)
.await
@@ -357,20 +356,19 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
}
fn remote_audience<'a>(
config: &'a crate::server::ServerConfig,
runtime_id: &str,
config: &'a ServerConfig,
workspace_id: &str,
) -> Result<std::borrow::Cow<'a, str>, WorkerMutationSourceProofError> {
if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID {
return Ok(std::borrow::Cow::Owned(format!("embedded:{workspace_id}")));
}
) -> Result<&'a str, WorkerMutationSourceProofError> {
config
.remote_runtime_sources
.iter()
.find(|runtime| runtime.runtime_id == runtime_id)
.and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref())
.map(|auth| std::borrow::Cow::Borrowed(auth.server_id.as_str()))
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)
.backend_base_url
.as_deref()
.map(str::trim)
.filter(|audience| !audience.is_empty())
.ok_or_else(|| {
WorkerMutationSourceProofError::Authority(format!(
"Backend public URL is unavailable for Workspace `{workspace_id}` source proof verification"
))
})
}
fn validate_in_process_claims(