fix: lock verified Runtime public keys in settings
This commit is contained in:
@@ -1839,6 +1839,15 @@ pub struct CreateRemoteRuntimeRequest {
|
|||||||
pub expected_revision: Option<u64>,
|
pub expected_revision: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct UpdateRemoteRuntimeRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub endpoint: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -3098,6 +3107,7 @@ pub fn catalog_typescript() -> String {
|
|||||||
RuntimeTrustConflictResponse::decl(&config),
|
RuntimeTrustConflictResponse::decl(&config),
|
||||||
RuntimePublicIdentityBundle::decl(&config),
|
RuntimePublicIdentityBundle::decl(&config),
|
||||||
CreateRemoteRuntimeRequest::decl(&config),
|
CreateRemoteRuntimeRequest::decl(&config),
|
||||||
|
UpdateRemoteRuntimeRequest::decl(&config),
|
||||||
RuntimeConnectionTestStatus::decl(&config),
|
RuntimeConnectionTestStatus::decl(&config),
|
||||||
RuntimeConnectionTestFailureKind::decl(&config),
|
RuntimeConnectionTestFailureKind::decl(&config),
|
||||||
RuntimeConnectionTestResponse::decl(&config),
|
RuntimeConnectionTestResponse::decl(&config),
|
||||||
@@ -3617,6 +3627,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_runtime_metadata_update_cannot_carry_public_key_authority() {
|
||||||
|
let request = UpdateRemoteRuntimeRequest {
|
||||||
|
display_name: Some("Runtime A".to_string()),
|
||||||
|
endpoint: "https://runtime.example.test".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&request).unwrap(),
|
||||||
|
serde_json::json!({
|
||||||
|
"display_name": "Runtime A",
|
||||||
|
"endpoint": "https://runtime.example.test",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<UpdateRemoteRuntimeRequest>(serde_json::json!({
|
||||||
|
"display_name": "Runtime A",
|
||||||
|
"endpoint": "https://runtime.example.test",
|
||||||
|
"public_bundle": {
|
||||||
|
"identity_id": "runtime-a",
|
||||||
|
"public_key": "yoi-ed25519-pub:v1:not-accepted",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.is_err(),
|
||||||
|
"metadata updates must reject public key fields"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_launch_optional_omission_and_request_shape_are_stable() {
|
fn worker_launch_optional_omission_and_request_shape_are_stable() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -93,9 +93,9 @@ use workspace_api::{
|
|||||||
RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry,
|
RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry,
|
||||||
RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyRevealResponse,
|
RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyRevealResponse,
|
||||||
RuntimeTrustKeyState, RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
RuntimeTrustKeyState, RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||||
TICKET_RELATIONS_QUERY_PATH, UpdateWorkspaceMetadataRequest, WhoamiResponse,
|
TICKET_RELATIONS_QUERY_PATH, UpdateRemoteRuntimeRequest, UpdateWorkspaceMetadataRequest,
|
||||||
WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption,
|
WhoamiResponse, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate,
|
||||||
WorkerLaunchWorkerSummary,
|
WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
|
||||||
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
||||||
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
|
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
|
||||||
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
||||||
@@ -3420,7 +3420,9 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/runtimes/{runtime_id}",
|
"/api/w/{workspace_id}/runtimes/{runtime_id}",
|
||||||
get(scoped_get_runtime_detail).delete(scoped_delete_remote_runtime),
|
get(scoped_get_runtime_detail)
|
||||||
|
.post(scoped_update_remote_runtime)
|
||||||
|
.delete(scoped_delete_remote_runtime),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
|
||||||
@@ -12075,6 +12077,60 @@ async fn scoped_get_runtime_detail(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_update_remote_runtime(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
|
Extension(actor): Extension<RequestActor>,
|
||||||
|
Json(request): Json<UpdateRemoteRuntimeRequest>,
|
||||||
|
) -> ApiResult<Json<WorkspaceRuntimeDetail>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
require_workspace_owner(
|
||||||
|
&api,
|
||||||
|
&path.workspace_id,
|
||||||
|
&actor,
|
||||||
|
"manage Workspace Runtimes",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"embedded_runtime_not_config_managed",
|
||||||
|
"the embedded Runtime is built in and cannot be edited",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let endpoint =
|
||||||
|
validate_runtime_metadata(request.display_name.as_deref(), &request.endpoint).await?;
|
||||||
|
let display_name = request
|
||||||
|
.display_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(&path.runtime_id);
|
||||||
|
let binding = api
|
||||||
|
.store
|
||||||
|
.update_workspace_runtime_binding_metadata(
|
||||||
|
&path.workspace_id,
|
||||||
|
&path.runtime_id,
|
||||||
|
display_name,
|
||||||
|
endpoint.as_str().trim_end_matches('/'),
|
||||||
|
&Utc::now().to_rfc3339(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let activate_runtime = binding.state == StoredRuntimeBindingState::Verified;
|
||||||
|
api.runtime_binding_expectations
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.insert(
|
||||||
|
(path.workspace_id.clone(), path.runtime_id.clone()),
|
||||||
|
binding.clone(),
|
||||||
|
);
|
||||||
|
api.runtime_subscription_broker
|
||||||
|
.unregister_runtime(&path.runtime_id);
|
||||||
|
api.register_workspace_runtime_binding(binding, activate_runtime)?;
|
||||||
|
Ok(Json(
|
||||||
|
workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_reveal_runtime_trust_key(
|
async fn scoped_reveal_runtime_trust_key(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
@@ -16593,7 +16649,11 @@ async fn validate_runtime_connection_request(
|
|||||||
"Runtime public bundle must contain a public key",
|
"Runtime public bundle must contain a public key",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let endpoint = Url::parse(request.endpoint.trim()).map_err(|_| {
|
validate_runtime_metadata(request.display_name.as_deref(), &request.endpoint).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_runtime_metadata(display_name: Option<&str>, endpoint: &str) -> ApiResult<Url> {
|
||||||
|
let endpoint = Url::parse(endpoint.trim()).map_err(|_| {
|
||||||
settings_bad_request(
|
settings_bad_request(
|
||||||
"invalid_remote_runtime_endpoint",
|
"invalid_remote_runtime_endpoint",
|
||||||
"endpoint must be an absolute HTTP or HTTPS URL",
|
"endpoint must be an absolute HTTP or HTTPS URL",
|
||||||
@@ -16656,11 +16716,7 @@ async fn validate_runtime_connection_request(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if request
|
if display_name.is_some_and(|value| value.is_empty() || value.chars().any(char::is_control)) {
|
||||||
.display_name
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|value| value.is_empty() || value.chars().any(char::is_control))
|
|
||||||
{
|
|
||||||
return Err(settings_bad_request(
|
return Err(settings_bad_request(
|
||||||
"invalid_remote_runtime_display_name",
|
"invalid_remote_runtime_display_name",
|
||||||
"display_name must be non-empty when supplied and cannot contain control characters",
|
"display_name must be non-empty when supplied and cannot contain control characters",
|
||||||
@@ -20869,6 +20925,124 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn verified_remote_runtime_metadata_update_preserves_public_key_and_verification() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
let actor = test_owner_actor();
|
||||||
|
let workspace_identity = api
|
||||||
|
.signing_identities
|
||||||
|
.provision_existing(&api.config.workspace_id, &actor.account_id)
|
||||||
|
.unwrap();
|
||||||
|
let runtime_identity = RuntimeIdentityMaterial::generate("verified-runtime").unwrap();
|
||||||
|
api.store
|
||||||
|
.upsert_workspace_runtime_binding_record(
|
||||||
|
WorkspaceRuntimeBinding {
|
||||||
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
|
runtime_id: "verified-runtime".to_string(),
|
||||||
|
display_name: "Old label".to_string(),
|
||||||
|
base_url: "https://8.8.8.8".to_string(),
|
||||||
|
public_key: runtime_identity.public_key.clone(),
|
||||||
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
|
state: StoredRuntimeBindingState::Configured,
|
||||||
|
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||||
|
workspace_key_id: Some(workspace_identity.key_id.clone()),
|
||||||
|
workspace_key_generation: Some(workspace_identity.revision),
|
||||||
|
created_at: "1".to_string(),
|
||||||
|
updated_at: "1".to_string(),
|
||||||
|
revoked_at: None,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let configured = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_binding(&api.config.workspace_id, "verified-runtime")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let evidence = crate::store::WorkspaceRuntimeVerificationEvidence {
|
||||||
|
workspace_id: configured.workspace_id.clone(),
|
||||||
|
runtime_id: configured.runtime_id.clone(),
|
||||||
|
binding_revision: configured.binding_revision,
|
||||||
|
workspace_key_id: workspace_identity.key_id,
|
||||||
|
workspace_identity_revision: workspace_identity.revision,
|
||||||
|
workspace_trust_generation: workspace_identity.revision,
|
||||||
|
runtime_public_key_fingerprint: configured.public_key_fingerprint.clone(),
|
||||||
|
runtime_identity_revision: 1,
|
||||||
|
challenge_id: "verified-metadata-update".to_string(),
|
||||||
|
state: "verified".to_string(),
|
||||||
|
last_outcome: "verified".to_string(),
|
||||||
|
verified_at: Some("2".to_string()),
|
||||||
|
checked_at: "2".to_string(),
|
||||||
|
};
|
||||||
|
api.store
|
||||||
|
.record_workspace_runtime_verification_attempt(&evidence)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
api.store
|
||||||
|
.complete_workspace_runtime_verification(&evidence)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let Json(updated) = scoped_update_remote_runtime(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedRuntimePath {
|
||||||
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
|
runtime_id: "verified-runtime".to_string(),
|
||||||
|
}),
|
||||||
|
Extension(actor.clone()),
|
||||||
|
Json(UpdateRemoteRuntimeRequest {
|
||||||
|
display_name: Some("New label".to_string()),
|
||||||
|
endpoint: "https://8.8.4.4".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(updated.runtime.runtime.label, "New label");
|
||||||
|
assert_eq!(updated.endpoint.as_deref(), Some("https://8.8.4.4"));
|
||||||
|
let binding = updated.runtime.management.binding.unwrap();
|
||||||
|
assert_eq!(binding.state, WorkspaceRuntimeBindingState::Verified);
|
||||||
|
assert_eq!(binding.revision, 1);
|
||||||
|
assert_eq!(binding.verification.unwrap().binding_revision, 1);
|
||||||
|
assert_eq!(
|
||||||
|
updated.trust_key.fingerprint.as_deref(),
|
||||||
|
Some(configured.public_key_fingerprint.as_str())
|
||||||
|
);
|
||||||
|
let stored = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_binding(&api.config.workspace_id, "verified-runtime")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored.public_key, runtime_identity.public_key);
|
||||||
|
assert_eq!(
|
||||||
|
stored.public_key_fingerprint,
|
||||||
|
configured.public_key_fingerprint
|
||||||
|
);
|
||||||
|
|
||||||
|
let replacement = RuntimeIdentityMaterial::generate("verified-runtime").unwrap();
|
||||||
|
let error = create_remote_runtime(
|
||||||
|
State(api),
|
||||||
|
Extension(actor),
|
||||||
|
Json(CreateRemoteRuntimeRequest {
|
||||||
|
public_bundle: workspace_api::RuntimePublicIdentityBundle {
|
||||||
|
identity_id: "verified-runtime".to_string(),
|
||||||
|
public_key: replacement.public_key,
|
||||||
|
},
|
||||||
|
display_name: Some("New label".to_string()),
|
||||||
|
endpoint: "https://8.8.4.4".to_string(),
|
||||||
|
expected_revision: Some(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.into_response().status(), StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn remote_runtime_registration_is_workspace_scoped_revisioned_and_configured() {
|
async fn remote_runtime_registration_is_workspace_scoped_revisioned_and_configured() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -832,6 +832,14 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
|||||||
expected_revision: Option<u64>,
|
expected_revision: Option<u64>,
|
||||||
actor_account_id: &str,
|
actor_account_id: &str,
|
||||||
) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)>;
|
) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)>;
|
||||||
|
async fn update_workspace_runtime_binding_metadata(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
display_name: &str,
|
||||||
|
base_url: &str,
|
||||||
|
updated_at: &str,
|
||||||
|
) -> Result<WorkspaceRuntimeBinding>;
|
||||||
async fn revoke_workspace_runtime_binding_key(
|
async fn revoke_workspace_runtime_binding_key(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -2277,6 +2285,59 @@ impl SqliteWorkspaceStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_workspace_runtime_binding_metadata(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
display_name: &str,
|
||||||
|
base_url: &str,
|
||||||
|
updated_at: &str,
|
||||||
|
) -> Result<WorkspaceRuntimeBinding> {
|
||||||
|
validate_identifier("workspace_id", workspace_id)?;
|
||||||
|
validate_identifier("runtime_id", runtime_id)?;
|
||||||
|
validate_non_empty("runtime display_name", display_name)?;
|
||||||
|
validate_runtime_base_url(base_url)?;
|
||||||
|
validate_non_empty("updated_at", updated_at)?;
|
||||||
|
self.with_conn_mut(|conn| {
|
||||||
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
let existing = tx
|
||||||
|
.query_row(
|
||||||
|
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
|
||||||
|
public_key_fingerprint, binding_revision, state, authentication_mode,
|
||||||
|
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
|
||||||
|
FROM workspace_runtime_bindings
|
||||||
|
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
|
||||||
|
params![workspace_id, runtime_id],
|
||||||
|
read_workspace_runtime_binding,
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| Error::RuntimeBindingNotFound {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
})?;
|
||||||
|
if existing.display_name == display_name && existing.base_url == base_url {
|
||||||
|
tx.commit()?;
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
tx.execute(
|
||||||
|
r#"UPDATE workspace_runtime_bindings
|
||||||
|
SET display_name = ?3, base_url = ?4, updated_at = ?5
|
||||||
|
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
|
||||||
|
params![workspace_id, runtime_id, display_name, base_url, updated_at],
|
||||||
|
)?;
|
||||||
|
let updated = tx.query_row(
|
||||||
|
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
|
||||||
|
public_key_fingerprint, binding_revision, state, authentication_mode,
|
||||||
|
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
|
||||||
|
FROM workspace_runtime_bindings
|
||||||
|
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
|
||||||
|
params![workspace_id, runtime_id],
|
||||||
|
read_workspace_runtime_binding,
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(updated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn revoke_workspace_runtime_binding_key(
|
pub fn revoke_workspace_runtime_binding_key(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -3505,6 +3566,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_workspace_runtime_binding_metadata(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
display_name: &str,
|
||||||
|
base_url: &str,
|
||||||
|
updated_at: &str,
|
||||||
|
) -> Result<WorkspaceRuntimeBinding> {
|
||||||
|
SqliteWorkspaceStore::update_workspace_runtime_binding_metadata(
|
||||||
|
self,
|
||||||
|
workspace_id,
|
||||||
|
runtime_id,
|
||||||
|
display_name,
|
||||||
|
base_url,
|
||||||
|
updated_at,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn revoke_workspace_runtime_binding_key(
|
async fn revoke_workspace_runtime_binding_key(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
|
|||||||
@@ -496,6 +496,11 @@ export type CreateRemoteRuntimeRequest = {
|
|||||||
expected_revision?: number | null;
|
expected_revision?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateRemoteRuntimeRequest = {
|
||||||
|
display_name?: string | null;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
||||||
|
|
||||||
export type RuntimeConnectionTestFailureKind =
|
export type RuntimeConnectionTestFailureKind =
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
RuntimeTrustKeyState,
|
RuntimeTrustKeyState,
|
||||||
RuntimeTrustKeyStatus,
|
RuntimeTrustKeyStatus,
|
||||||
RuntimeVerificationEvidenceSummary,
|
RuntimeVerificationEvidenceSummary,
|
||||||
|
UpdateRemoteRuntimeRequest,
|
||||||
WorkspaceRuntimeBindingState,
|
WorkspaceRuntimeBindingState,
|
||||||
WorkspaceRuntimeBindingSummary,
|
WorkspaceRuntimeBindingSummary,
|
||||||
WorkspaceRuntimeDetail,
|
WorkspaceRuntimeDetail,
|
||||||
@@ -919,6 +920,26 @@ export async function createRemoteRuntime(
|
|||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateRemoteRuntime(
|
||||||
|
workspaceId: string,
|
||||||
|
runtimeId: string,
|
||||||
|
request: UpdateRemoteRuntimeRequest,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<WorkspaceRuntimeDetail> {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
workspaceApiPath(
|
||||||
|
workspaceId,
|
||||||
|
`/runtimes/${encodeURIComponent(runtimeId)}`,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return finishMutation(response, workspaceId, runtimeId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRemoteRuntime(
|
export async function deleteRemoteRuntime(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
runtimeId: string,
|
runtimeId: string,
|
||||||
|
|||||||
@@ -445,6 +445,20 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtime-public-key-readonly {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
max-width: 56rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-public-key-readonly p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-public-key-readonly code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.runtime-trust-form {
|
.runtime-trust-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
|||||||
+110
-5
@@ -10,6 +10,7 @@
|
|||||||
previewRuntimePublicKeyFingerprint,
|
previewRuntimePublicKeyFingerprint,
|
||||||
revealRuntimeTrustKey,
|
revealRuntimeTrustKey,
|
||||||
revokeRuntimeTrustKey,
|
revokeRuntimeTrustKey,
|
||||||
|
updateRemoteRuntime,
|
||||||
RuntimeTrustConflictError,
|
RuntimeTrustConflictError,
|
||||||
RuntimeTrustRouteFence,
|
RuntimeTrustRouteFence,
|
||||||
RuntimeTrustRequestError,
|
RuntimeTrustRequestError,
|
||||||
@@ -23,8 +24,11 @@
|
|||||||
let showPublicKey = $state(false);
|
let showPublicKey = $state(false);
|
||||||
let revealedPublicKey = $state<string | null>(null);
|
let revealedPublicKey = $state<string | null>(null);
|
||||||
let publicKey = $state('');
|
let publicKey = $state('');
|
||||||
|
let displayName = $state('');
|
||||||
|
let endpoint = $state('');
|
||||||
|
let editingMetadata = $state(false);
|
||||||
let deleteRuntimeConfirmation = $state('');
|
let deleteRuntimeConfirmation = $state('');
|
||||||
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
|
let busyAction = $state<'metadata' | 'trust' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
|
||||||
let fieldError = $state<string | null>(null);
|
let fieldError = $state<string | null>(null);
|
||||||
let deleteRuntimeError = $state<string | null>(null);
|
let deleteRuntimeError = $state<string | null>(null);
|
||||||
let requestError = $state<string | null>(null);
|
let requestError = $state<string | null>(null);
|
||||||
@@ -43,6 +47,9 @@
|
|||||||
showPublicKey = false;
|
showPublicKey = false;
|
||||||
revealedPublicKey = null;
|
revealedPublicKey = null;
|
||||||
publicKey = '';
|
publicKey = '';
|
||||||
|
displayName = data.runtimeDetail?.runtime.label ?? '';
|
||||||
|
endpoint = data.runtimeDetail?.endpoint ?? '';
|
||||||
|
editingMetadata = false;
|
||||||
deleteRuntimeConfirmation = '';
|
deleteRuntimeConfirmation = '';
|
||||||
busyAction = null;
|
busyAction = null;
|
||||||
fieldError = null;
|
fieldError = null;
|
||||||
@@ -105,6 +112,49 @@
|
|||||||
return routeFence.isCurrent(operation, data.runtimeId);
|
return routeFence.isCurrent(operation, data.runtimeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelRuntimeMetadataEdit(): void {
|
||||||
|
displayName = data.runtimeDetail?.runtime.label ?? '';
|
||||||
|
endpoint = data.runtimeDetail?.endpoint ?? '';
|
||||||
|
editingMetadata = false;
|
||||||
|
requestError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRuntimeMetadata(event: SubmitEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busyAction !== null || !data.runtimeDetail) return;
|
||||||
|
|
||||||
|
requestError = null;
|
||||||
|
successMessage = null;
|
||||||
|
const normalizedDisplayName = displayName.trim();
|
||||||
|
const normalizedEndpoint = endpoint.trim();
|
||||||
|
if (!normalizedDisplayName) {
|
||||||
|
requestError = 'Enter a Runtime label.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!normalizedEndpoint) {
|
||||||
|
requestError = 'Enter the Runtime endpoint.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
|
busyAction = 'metadata';
|
||||||
|
try {
|
||||||
|
await updateRemoteRuntime(data.workspaceId, operation.runtimeId, {
|
||||||
|
display_name: normalizedDisplayName,
|
||||||
|
endpoint: normalizedEndpoint,
|
||||||
|
});
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
successMessage = 'Runtime settings were updated.';
|
||||||
|
editingMetadata = false;
|
||||||
|
await reloadAuthority();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
requestError = error instanceof Error ? error.message : 'Runtime settings update failed.';
|
||||||
|
} finally {
|
||||||
|
if (isCurrentRoute(operation)) busyAction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTrustKey(event: SubmitEvent): Promise<void> {
|
async function saveTrustKey(event: SubmitEvent): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (busyAction !== null || !data.runtimeDetail) return;
|
if (busyAction !== null || !data.runtimeDetail) return;
|
||||||
@@ -135,7 +185,7 @@
|
|||||||
const action = trustAction(trust.status);
|
const action = trustAction(trust.status);
|
||||||
|
|
||||||
const operation = routeFence.capture(data.runtimeId);
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
busyAction = 'save';
|
busyAction = 'trust';
|
||||||
try {
|
try {
|
||||||
const binding = data.runtimeDetail.runtime.management.binding;
|
const binding = data.runtimeDetail.runtime.management.binding;
|
||||||
if (!binding || !data.runtimeDetail.endpoint) {
|
if (!binding || !data.runtimeDetail.endpoint) {
|
||||||
@@ -339,6 +389,7 @@
|
|||||||
{@const detail = data.runtimeDetail}
|
{@const detail = data.runtimeDetail}
|
||||||
{@const runtime = detail.runtime}
|
{@const runtime = detail.runtime}
|
||||||
{@const trust = detail.trust_key}
|
{@const trust = detail.trust_key}
|
||||||
|
{@const verifiedTrust = runtime.management.binding?.state === 'verified'}
|
||||||
{@const currentAction = trustAction(trust.status)}
|
{@const currentAction = trustAction(trust.status)}
|
||||||
|
|
||||||
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
|
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
|
||||||
@@ -373,6 +424,50 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
|
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
|
||||||
|
<section class="runtime-detail-section" aria-labelledby="runtime-settings-heading">
|
||||||
|
<h2 id="runtime-settings-heading">Runtime settings</h2>
|
||||||
|
{#if editingMetadata}
|
||||||
|
<form class="runtime-trust-form" onsubmit={saveRuntimeMetadata}>
|
||||||
|
<label for="runtime-display-name-input">Label</label>
|
||||||
|
<input
|
||||||
|
id="runtime-display-name-input"
|
||||||
|
bind:value={displayName}
|
||||||
|
autocomplete="off"
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
/>
|
||||||
|
<label for="runtime-endpoint-input">Endpoint</label>
|
||||||
|
<input
|
||||||
|
id="runtime-endpoint-input"
|
||||||
|
bind:value={endpoint}
|
||||||
|
inputmode="url"
|
||||||
|
autocomplete="url"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
/>
|
||||||
|
<div class="settings-action-row">
|
||||||
|
<button type="submit" disabled={busyAction !== null}>
|
||||||
|
{busyAction === 'metadata' ? 'Saving…' : 'Save Runtime settings'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="settings-secondary-action"
|
||||||
|
type="button"
|
||||||
|
onclick={cancelRuntimeMetadataEdit}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div class="settings-action-row">
|
||||||
|
<button
|
||||||
|
class="settings-secondary-action"
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editingMetadata = true)}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
>Edit Runtime</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
|
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
|
||||||
<h2 id="runtime-trust-heading">Workspace trust</h2>
|
<h2 id="runtime-trust-heading">Workspace trust</h2>
|
||||||
|
|
||||||
@@ -395,7 +490,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
{#if verifiedTrust}
|
||||||
|
<div class="runtime-public-key-readonly">
|
||||||
|
<strong>Runtime public key</strong>
|
||||||
|
<p>
|
||||||
|
This verified key is read-only. Revoke Workspace trust and register the Runtime again to use a different public key.
|
||||||
|
</p>
|
||||||
|
<code>{trust.fingerprint}</code>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
||||||
<label for="runtime-public-key-input">Runtime public key</label>
|
<label for="runtime-public-key-input">Runtime public key</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="runtime-public-key-input"
|
id="runtime-public-key-input"
|
||||||
@@ -427,10 +531,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="settings-action-row">
|
<div class="settings-action-row">
|
||||||
<button type="submit" disabled={busyAction !== null}>
|
<button type="submit" disabled={busyAction !== null}>
|
||||||
{busyAction === 'save' ? 'Saving…' : actionLabel(currentAction)}
|
{busyAction === 'trust' ? 'Saving…' : actionLabel(currentAction)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="runtime-revoke-row">
|
<div class="runtime-revoke-row">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -129,8 +129,29 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
|||||||
|
|
||||||
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
|
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
|
||||||
const reveal = page.indexOf("Reveal public key");
|
const reveal = page.indexOf("Reveal public key");
|
||||||
|
const verifiedKey = page.indexOf("{#if verifiedTrust}");
|
||||||
const mutation = page.indexOf('id="runtime-public-key-input"');
|
const mutation = page.indexOf('id="runtime-public-key-input"');
|
||||||
|
const metadataStart = page.indexOf("async function saveRuntimeMetadata");
|
||||||
|
const trustStart = page.indexOf("async function saveTrustKey");
|
||||||
|
const metadataMutation = page.slice(metadataStart, trustStart);
|
||||||
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
|
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
|
||||||
|
assert(
|
||||||
|
verifiedKey >= 0 && verifiedKey < mutation &&
|
||||||
|
page.slice(verifiedKey, mutation).includes("{:else}"),
|
||||||
|
"verified Runtime public key must be read-only while unverified trust keeps key input",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
metadataMutation.includes("updateRemoteRuntime(") &&
|
||||||
|
metadataMutation.includes("display_name: normalizedDisplayName") &&
|
||||||
|
metadataMutation.includes("endpoint: normalizedEndpoint"),
|
||||||
|
"mutable Runtime settings should use the metadata-only update request",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!metadataMutation.includes("publicKey") &&
|
||||||
|
!metadataMutation.includes("public_bundle") &&
|
||||||
|
!metadataMutation.includes("public_key"),
|
||||||
|
"verified Runtime metadata updates must not carry public key authority",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
page.includes("Current fingerprint"),
|
page.includes("Current fingerprint"),
|
||||||
"current fingerprint must be explicit",
|
"current fingerprint must be explicit",
|
||||||
@@ -168,6 +189,10 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
|||||||
"routeFence.enter(data.runtimeId)",
|
"routeFence.enter(data.runtimeId)",
|
||||||
"showPublicKey = false",
|
"showPublicKey = false",
|
||||||
"revealedPublicKey = null",
|
"revealedPublicKey = null",
|
||||||
|
"saveRuntimeMetadata",
|
||||||
|
"Runtime settings",
|
||||||
|
"Edit Runtime",
|
||||||
|
"This verified key is read-only",
|
||||||
"publicKey = ''",
|
"publicKey = ''",
|
||||||
"requestError = null",
|
"requestError = null",
|
||||||
"successMessage = null",
|
"successMessage = null",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
revokeRuntimeTrustKey,
|
revokeRuntimeTrustKey,
|
||||||
RuntimeTrustConflictError,
|
RuntimeTrustConflictError,
|
||||||
RuntimeTrustRouteFence,
|
RuntimeTrustRouteFence,
|
||||||
|
updateRemoteRuntime,
|
||||||
} from "../src/lib/workspace/api/runtime-management.ts";
|
} from "../src/lib/workspace/api/runtime-management.ts";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): asserts condition {
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
@@ -273,6 +274,45 @@ Deno.test("mismatched revoke fingerprint never sends a request", async () => {
|
|||||||
assert(requests === 0, "mismatched fingerprint sent a revoke request");
|
assert(requests === 0, "mismatched fingerprint sent a revoke request");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime metadata update never sends public key authority", async () => {
|
||||||
|
let requestedUrl = "";
|
||||||
|
let requestedMethod = "";
|
||||||
|
let requestedBody: unknown = null;
|
||||||
|
const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
requestedUrl = String(input);
|
||||||
|
requestedMethod = init?.method ?? "GET";
|
||||||
|
requestedBody = JSON.parse(String(init?.body));
|
||||||
|
return Promise.resolve(Response.json(detail()));
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
await updateRemoteRuntime(
|
||||||
|
"workspace-a",
|
||||||
|
"arcadia",
|
||||||
|
{
|
||||||
|
display_name: "Updated Runtime",
|
||||||
|
endpoint: "https://runtime.example.test/v2",
|
||||||
|
},
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
requestedUrl === "/api/w/workspace-a/runtimes/arcadia",
|
||||||
|
`unexpected update URL: ${requestedUrl}`,
|
||||||
|
);
|
||||||
|
assert(requestedMethod === "POST", "Runtime update must use POST");
|
||||||
|
assert(
|
||||||
|
JSON.stringify(requestedBody) ===
|
||||||
|
JSON.stringify({
|
||||||
|
display_name: "Updated Runtime",
|
||||||
|
endpoint: "https://runtime.example.test/v2",
|
||||||
|
}),
|
||||||
|
`unexpected update body: ${JSON.stringify(requestedBody)}`,
|
||||||
|
);
|
||||||
|
const body = requestedBody as Record<string, unknown>;
|
||||||
|
assert(!("public_bundle" in body), "metadata update sent public_bundle");
|
||||||
|
assert(!("public_key" in body), "metadata update sent public_key");
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Runtime registration delete uses the Workspace-scoped resource route", async () => {
|
Deno.test("Runtime registration delete uses the Workspace-scoped resource route", async () => {
|
||||||
let requestedUrl = "";
|
let requestedUrl = "";
|
||||||
let requestedMethod = "";
|
let requestedMethod = "";
|
||||||
|
|||||||
Reference in New Issue
Block a user