refactor: remove server-global runtime trust
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user