feat: add runtime trust capability auth

This commit is contained in:
2026-07-26 20:45:52 +09:00
parent 006762f900
commit d994268a6b
12 changed files with 1235 additions and 33 deletions
+71 -6
View File
@@ -13,6 +13,7 @@ use std::{
sync::{Arc, RwLock},
time::Duration,
};
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
@@ -2015,11 +2016,18 @@ pub struct RemoteRuntimeConfig {
pub display_name: String,
pub base_url: String,
pub bearer_token: Option<String>,
pub auth: Option<RemoteRuntimeAuthConfig>,
pub cached_capabilities: RuntimeCapabilitySummary,
pub cached_status: String,
pub timeout: Duration,
}
#[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")
@@ -2030,6 +2038,7 @@ 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("cached_capabilities", &self.cached_capabilities)
.field("cached_status", &self.cached_status)
.field("timeout", &self.timeout)
@@ -2049,6 +2058,7 @@ impl RemoteRuntimeConfig {
display_name: display_name.into(),
base_url: base_url.into(),
bearer_token,
auth: None,
cached_capabilities: remote_runtime_capabilities(
200, false, false, "unknown", "unknown",
),
@@ -2062,6 +2072,11 @@ impl RemoteRuntimeConfig {
self
}
pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self {
self.auth = Some(auth);
self
}
pub fn with_cached_status(mut self, status: impl Into<String>) -> Self {
self.cached_status = status.into();
self
@@ -2081,6 +2096,7 @@ pub struct RemoteWorkerRuntime {
backend_base_url: String,
workspace_id: String,
bearer_token: Option<String>,
auth: Option<RemoteRuntimeAuthConfig>,
cached_capabilities: RuntimeCapabilitySummary,
cached_status: String,
host_id: String,
@@ -2088,6 +2104,21 @@ pub struct RemoteWorkerRuntime {
http: BlockingHttpClient,
}
fn all_remote_runtime_permissions() -> Vec<String> {
[
"workers:list",
"workers:create",
"workers:read",
"workers:delete",
"workers:input",
"workers:stop",
"workers:protocol",
]
.into_iter()
.map(str::to_string)
.collect()
}
impl RemoteWorkerRuntime {
pub fn new(
config: RemoteRuntimeConfig,
@@ -2112,6 +2143,7 @@ impl RemoteWorkerRuntime {
backend_base_url: backend_base_url.trim_end_matches('/').to_string(),
workspace_id,
bearer_token: config.bearer_token,
auth: config.auth,
cached_capabilities: config.cached_capabilities,
cached_status: config.cached_status,
resource_broker: BackendResourceBroker::default(),
@@ -2150,7 +2182,7 @@ impl RemoteWorkerRuntime {
where
T: DeserializeOwned + Send + 'static,
{
self.send_json(self.http.get(self.endpoint(path)))
self.send_json(path, self.http.get(self.endpoint(path)))
}
fn post_json<B, T>(&self, path: &str, body: &B) -> Result<T, RuntimeDiagnostic>
@@ -2158,25 +2190,56 @@ impl RemoteWorkerRuntime {
B: Serialize + ?Sized,
T: DeserializeOwned + Send + 'static,
{
self.send_json(self.http.post(self.endpoint(path)).json(body))
self.send_json(path, self.http.post(self.endpoint(path)).json(body))
}
fn delete_json<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
where
T: DeserializeOwned + Send + 'static,
{
self.send_json(self.http.delete(self.endpoint(path)))
self.send_json(path, self.http.delete(self.endpoint(path)))
}
fn send_json<T>(&self, request: RequestBuilder) -> Result<T, RuntimeDiagnostic>
fn runtime_capability_token(&self, path: &str) -> 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,
all_remote_runtime_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 send_json<T>(&self, path: &str, request: RequestBuilder) -> Result<T, RuntimeDiagnostic>
where
T: DeserializeOwned + Send + 'static,
{
let runtime_id = self.runtime_id.clone();
let bearer_token = self.bearer_token.clone();
let capability_token = self.runtime_capability_token(path);
run_blocking_http(move || {
let request = request.header(CONTENT_TYPE, "application/json");
let request = if let Some(token) = bearer_token.as_deref() {
let request = if let Some(token) = capability_token.as_deref().or(bearer_token.as_deref()) {
request.header(AUTHORIZATION, format!("Bearer {token}"))
} else {
request
@@ -2653,7 +2716,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
runtime_id: self.runtime_id.clone(),
worker_id: worker_id.to_string(),
endpoint: self.ws_endpoint(worker_id),
bearer_token: self.bearer_token.clone(),
bearer_token: self
.runtime_capability_token(&format!("/v1/workers/{worker_id}/protocol"))
.or_else(|| self.bearer_token.clone()),
},
))
}
+294 -4
View File
@@ -1,12 +1,17 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use yoi_workspace_server::store::RepositoryRecord;
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::store::{RepositoryRecord, SqliteWorkspaceStore, TrustedRuntimeRecord};
use yoi_workspace_server::{
BackendRuntimesConfigFile, ControlPlaneStore, ServerConfig, SqliteWorkspaceStore,
BackendRuntimesConfigFile, ControlPlaneStore, ServerConfig,
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceIdentity,
WorkspaceRecord, serve,
};
@@ -17,6 +22,8 @@ enum Command {
Init(InitOptions),
ConfigDefault,
ConfigDiff(WorkspacePathOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
Skills(SkillsCommand),
Help,
}
@@ -72,6 +79,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Command::Init(options) => run_init(options).await,
Command::ConfigDefault => run_config_default(),
Command::ConfigDiff(options) => run_config_diff(options),
Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::Skills(command) => run_skills(command),
Command::Help => Ok(()),
}
@@ -92,6 +101,8 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Init(parse_init_options(rest)?))
}
"config" => parse_config_command(rest),
"identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"skills" => parse_skills_command(rest),
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
@@ -105,7 +116,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help)
}
other => Err(CliError(format!(
"unknown command `{other}`; expected `init`, `config`, `skills`, or `serve`"
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `skills`, or `serve`"
))),
}
}
@@ -169,6 +180,254 @@ fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::err
Ok(())
}
#[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.unwrap_or_else(|| "server-main".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 base_url = None;
let mut public_key = None;
let mut display_name = None;
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)?),
"--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)?),
_ => 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 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();
store.upsert_trusted_runtime(&TrustedRuntimeRecord {
runtime_id: runtime_id.clone(),
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
base_url,
public_key,
created_at: now.clone(),
updated_at: now,
revoked_at: None,
})?;
println!("trusted_runtime_id={runtime_id}");
println!("server_db={}", database_path.display());
Ok(())
}
"list" => {
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() {
"--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 records = store.list_trusted_runtimes(include_revoked)?;
if json {
println!("{}", serde_json::to_string_pretty(&records)?);
} else {
for runtime in records {
println!(
"runtime_id={} base_url={} public_key={} revoked_at={}",
runtime.runtime_id,
runtime.base_url,
runtime.public_key,
runtime.revoked_at.unwrap_or_default()
);
}
}
Ok(())
}
"revoke" => {
let mut runtime_id = None;
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)?),
_ => return Err(Box::new(CliError(format!("unknown trust-runtime revoke argument `{flag}`")))),
}
}
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_trusted_runtime(&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) => {
@@ -224,6 +483,7 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
)?;
resolved.database_path = database_path.clone();
resolved.server.database_path = database_path.clone();
append_trusted_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
if let Some(listen) = options.listen {
resolved = resolved.with_listen(listen);
}
@@ -239,6 +499,36 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
Ok(())
}
fn append_trusted_runtime_sources(
store: &SqliteWorkspaceStore,
remote_runtime_sources: &mut Vec<RemoteRuntimeConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else {
if !store.list_trusted_runtimes(false)?.is_empty() {
return Err(Box::new(CliError(
"trusted runtimes are registered but server identity is not initialized; run `yoi-workspace-server identity init`".to_string(),
)));
}
return Ok(());
};
for runtime in store.list_trusted_runtimes(false)? {
let auth = RemoteRuntimeAuthConfig {
server_id: server_identity.identity.identity_id.clone(),
server_private_key: server_identity.identity.private_key.clone(),
};
let remote = RemoteRuntimeConfig::new(
runtime.runtime_id.clone(),
runtime.display_name,
runtime.base_url,
None,
)
.with_auth(auth);
remote_runtime_sources.retain(|existing| existing.runtime_id != runtime.runtime_id);
remote_runtime_sources.push(remote);
}
Ok(())
}
fn select_serve_workspace(store: &SqliteWorkspaceStore) -> Result<WorkspaceRecord, CliError> {
let workspaces = store
.list_workspaces()
+98
View File
@@ -72,6 +72,11 @@ const MIGRATIONS: &[Migration] = &[
name: "sqlite memory authority documents and staging resolutions",
apply: create_memory_authority_tables,
},
Migration {
version: 12,
name: "trusted remote runtime registry",
apply: create_trusted_runtime_registry_tables,
},
];
struct Migration {
@@ -107,6 +112,17 @@ pub struct RepositoryRecord {
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrustedRuntimeRecord {
pub runtime_id: String,
pub display_name: String,
pub base_url: String,
pub public_key: String,
pub created_at: String,
pub updated_at: String,
pub revoked_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AccountRecord {
pub account_id: String,
@@ -498,6 +514,59 @@ impl SqliteWorkspaceStore {
.map_err(|_| Error::Store("sqlite connection lock poisoned".to_string()))?;
f(&conn)
}
pub fn upsert_trusted_runtime(&self, record: &TrustedRuntimeRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
r#"INSERT INTO trusted_runtime_records (
runtime_id, display_name, base_url, public_key, created_at, updated_at, revoked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(runtime_id) DO UPDATE SET
display_name = excluded.display_name,
base_url = excluded.base_url,
public_key = excluded.public_key,
updated_at = excluded.updated_at,
revoked_at = excluded.revoked_at"#,
params![
record.runtime_id,
record.display_name,
record.base_url,
record.public_key,
record.created_at,
record.updated_at,
record.revoked_at,
],
)?;
Ok(())
})
}
pub fn list_trusted_runtimes(&self, include_revoked: bool) -> Result<Vec<TrustedRuntimeRecord>> {
self.with_conn(|conn| {
let sql = if include_revoked {
r#"SELECT runtime_id, display_name, base_url, public_key, created_at, updated_at, revoked_at
FROM trusted_runtime_records ORDER BY runtime_id ASC"#
} else {
r#"SELECT runtime_id, display_name, base_url, public_key, created_at, updated_at, revoked_at
FROM trusted_runtime_records WHERE revoked_at IS NULL ORDER BY runtime_id ASC"#
};
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([], read_trusted_runtime_record)?;
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Error::from)
})
}
pub fn revoke_trusted_runtime(&self, runtime_id: &str, revoked_at: &str) -> Result<bool> {
self.with_conn(|conn| {
let changed = conn.execute(
r#"UPDATE trusted_runtime_records
SET revoked_at = ?2, updated_at = ?2
WHERE runtime_id = ?1 AND revoked_at IS NULL"#,
params![runtime_id, revoked_at],
)?;
Ok(changed > 0)
})
}
}
#[async_trait]
@@ -1599,6 +1668,18 @@ fn account_select_sql(where_clause: &str) -> String {
)
}
fn read_trusted_runtime_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<TrustedRuntimeRecord> {
Ok(TrustedRuntimeRecord {
runtime_id: row.get(0)?,
display_name: row.get(1)?,
base_url: row.get(2)?,
public_key: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
revoked_at: row.get(6)?,
})
}
fn read_account_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<AccountRecord> {
Ok(AccountRecord {
account_id: row.get(0)?,
@@ -2005,6 +2086,23 @@ CREATE TABLE IF NOT EXISTS memory_staging_resolutions (
Ok(())
}
fn create_trusted_runtime_registry_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS trusted_runtime_records (
runtime_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
base_url TEXT NOT NULL,
public_key TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revoked_at TEXT
);
"#,
)?;
Ok(())
}
fn create_account_identity_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"