feat: add workspace runtime trust key management
This commit is contained in:
@@ -12,8 +12,10 @@ use workspace_api::{
|
||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse,
|
||||
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest,
|
||||
RevokeRuntimeTrustKeyRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
||||
WorkspaceRuntimeResource,
|
||||
};
|
||||
|
||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
@@ -241,6 +243,43 @@ impl BackendWorkspaceProductClient {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_runtimes(
|
||||
&self,
|
||||
) -> Result<ListResponse<WorkspaceRuntimeResource>, BackendWorkspaceClientError> {
|
||||
self.get_json("/runtimes")
|
||||
}
|
||||
|
||||
pub fn runtime_detail(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||
self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id)))
|
||||
}
|
||||
|
||||
pub fn put_runtime_trust_key(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
request: &PutRuntimeTrustKeyRequest,
|
||||
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||
self.send_json(
|
||||
Method::PUT,
|
||||
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
|
||||
Some(request),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn revoke_runtime_trust_key(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
request: &RevokeRuntimeTrustKeyRequest,
|
||||
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||
self.send_json(
|
||||
Method::DELETE,
|
||||
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
|
||||
Some(request),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
|
||||
self.get_json("/memory")
|
||||
}
|
||||
|
||||
@@ -539,6 +539,7 @@ pub enum WorkspaceAuthConfig {
|
||||
pub struct WorkspacePermissionSummary {
|
||||
pub manage_repositories: bool,
|
||||
pub manage_secrets: bool,
|
||||
pub manage_runtimes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -1135,6 +1136,7 @@ pub struct ObjectiveLinkTicketRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeSourceKind {
|
||||
EmbeddedWorkerRuntime,
|
||||
@@ -1142,6 +1144,7 @@ pub enum RuntimeSourceKind {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeSourceStatus {
|
||||
Active,
|
||||
@@ -1149,6 +1152,7 @@ pub enum RuntimeSourceStatus {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeIdentityAuthority {
|
||||
RuntimeRegistryProjection,
|
||||
@@ -1156,6 +1160,8 @@ pub enum RuntimeIdentityAuthority {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeSourceSummary {
|
||||
pub kind: RuntimeSourceKind,
|
||||
pub status: RuntimeSourceStatus,
|
||||
@@ -1164,6 +1170,7 @@ pub struct RuntimeSourceSummary {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct RuntimeSummary {
|
||||
pub runtime_id: String,
|
||||
pub label: String,
|
||||
@@ -1180,6 +1187,8 @@ pub struct RuntimeSummary {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeManagementSummary {
|
||||
pub built_in: bool,
|
||||
pub config_managed: bool,
|
||||
@@ -1189,12 +1198,119 @@ pub struct RuntimeManagementSummary {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct WorkspaceRuntimeResource {
|
||||
#[serde(flatten)]
|
||||
pub runtime: RuntimeSummary,
|
||||
pub management: RuntimeManagementSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeTrustKeyStatus {
|
||||
Unconfigured,
|
||||
Active,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeTrustKeyState {
|
||||
pub status: RuntimeTrustKeyStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub public_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fingerprint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||
pub revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeTrustAuditAction {
|
||||
Created,
|
||||
Replaced,
|
||||
Reactivated,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeTrustAuditEntry {
|
||||
pub action: RuntimeTrustAuditAction,
|
||||
pub actor_account_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub old_fingerprint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub new_fingerprint: Option<String>,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub revision: u64,
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceRuntimeDetail {
|
||||
pub workspace_id: String,
|
||||
pub runtime: WorkspaceRuntimeResource,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
pub trust_key: RuntimeTrustKeyState,
|
||||
#[serde(default)]
|
||||
pub recent_audit: Vec<RuntimeTrustAuditEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PutRuntimeTrustKeyRequest {
|
||||
pub public_key: String,
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RevokeRuntimeTrustKeyRequest {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeTrustConflictKind {
|
||||
StaleRevision,
|
||||
FingerprintInUse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeTrustConflictResponse {
|
||||
pub error: RuntimeTrustConflictKind,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub current_revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateRemoteRuntimeRequest {
|
||||
@@ -2394,6 +2510,22 @@ pub fn catalog_typescript() -> String {
|
||||
RepositoryListResponse::decl(&config),
|
||||
RepositoryDetailResponse::decl(&config),
|
||||
RepositoryLogResponse::decl(&config),
|
||||
RuntimeSourceKind::decl(&config),
|
||||
RuntimeSourceStatus::decl(&config),
|
||||
RuntimeIdentityAuthority::decl(&config),
|
||||
RuntimeSourceSummary::decl(&config),
|
||||
RuntimeSummary::decl(&config),
|
||||
RuntimeManagementSummary::decl(&config),
|
||||
WorkspaceRuntimeResource::decl(&config),
|
||||
RuntimeTrustKeyStatus::decl(&config),
|
||||
RuntimeTrustKeyState::decl(&config),
|
||||
RuntimeTrustAuditAction::decl(&config),
|
||||
RuntimeTrustAuditEntry::decl(&config),
|
||||
WorkspaceRuntimeDetail::decl(&config),
|
||||
PutRuntimeTrustKeyRequest::decl(&config),
|
||||
RevokeRuntimeTrustKeyRequest::decl(&config),
|
||||
RuntimeTrustConflictKind::decl(&config),
|
||||
RuntimeTrustConflictResponse::decl(&config),
|
||||
RuntimeConnectionTestStatus::decl(&config),
|
||||
RuntimeConnectionTestFailureKind::decl(&config),
|
||||
RuntimeConnectionTestResponse::decl(&config),
|
||||
@@ -3022,7 +3154,8 @@ mod tests {
|
||||
}},
|
||||
"permissions": {
|
||||
"manage_repositories": true,
|
||||
"manage_secrets": true
|
||||
"manage_secrets": true,
|
||||
"manage_runtimes": true
|
||||
},
|
||||
"extension_points": {
|
||||
"store": "sqlite",
|
||||
@@ -3086,6 +3219,75 @@ mod tests {
|
||||
assert!(serde_json::from_value::<RepositoryListResponse>(stale).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_detail_and_trust_mutations_are_closed_and_typed() {
|
||||
let detail = serde_json::json!({
|
||||
"workspace_id": "workspace-test",
|
||||
"runtime": {
|
||||
"runtime_id": "runtime-test",
|
||||
"label": "Runtime Test",
|
||||
"kind": "remote_http",
|
||||
"status": "active",
|
||||
"source": {
|
||||
"kind": "remote_http",
|
||||
"status": "active",
|
||||
"identity_authority": "runtime_registry_projection",
|
||||
"note": "active"
|
||||
},
|
||||
"host_ids": [],
|
||||
"worker_creation_available": true,
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"diagnostics": [],
|
||||
"management": {
|
||||
"built_in": false,
|
||||
"config_managed": true,
|
||||
"removable": true,
|
||||
"endpoint_configured": true,
|
||||
"token_ref_configured": false
|
||||
}
|
||||
},
|
||||
"endpoint": "https://runtime.example",
|
||||
"trust_key": {
|
||||
"status": "active",
|
||||
"public_key": "ssh-ed25519 AAAA runtime-test",
|
||||
"fingerprint": "SHA256:test",
|
||||
"revision": 2,
|
||||
"created_at": "2026-09-01T12:00:00Z",
|
||||
"updated_at": "2026-09-01T13:00:00Z"
|
||||
},
|
||||
"recent_audit": [{
|
||||
"action": "replaced",
|
||||
"actor_account_id": "account-owner",
|
||||
"old_fingerprint": "SHA256:old",
|
||||
"new_fingerprint": "SHA256:test",
|
||||
"revision": 2,
|
||||
"at": "2026-09-01T13:00:00Z"
|
||||
}]
|
||||
});
|
||||
let parsed: WorkspaceRuntimeDetail = serde_json::from_value(detail.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(parsed).unwrap(), detail);
|
||||
|
||||
let mut unknown = detail;
|
||||
unknown["trust_key"]["private_key"] = serde_json::json!("forbidden");
|
||||
assert!(serde_json::from_value::<WorkspaceRuntimeDetail>(unknown).is_err());
|
||||
assert!(
|
||||
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
|
||||
"public_key": "key",
|
||||
"expected_revision": 1,
|
||||
"replace": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
|
||||
"expected_revision": 1,
|
||||
"delete_runtime": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_connection_test_response_is_closed_and_typed() {
|
||||
let compatible = serde_json::json!({
|
||||
|
||||
@@ -440,6 +440,7 @@ CREATE TABLE workspace_runtime_bindings (
|
||||
base_url TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
binding_revision INTEGER NOT NULL CHECK (binding_revision > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
@@ -447,6 +448,22 @@ CREATE TABLE workspace_runtime_bindings (
|
||||
UNIQUE (workspace_id, public_key_fingerprint),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE TABLE workspace_runtime_binding_audit (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')),
|
||||
old_fingerprint TEXT,
|
||||
new_fingerprint TEXT,
|
||||
binding_revision INTEGER NOT NULL CHECK (binding_revision > 0),
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id, binding_revision),
|
||||
FOREIGN KEY(workspace_id, runtime_id)
|
||||
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX idx_workspace_runtime_binding_audit_recent
|
||||
ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC);
|
||||
CREATE TABLE typed_ticket_artifacts (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
||||
|
||||
@@ -120,6 +120,15 @@ pub enum Error {
|
||||
WorkspaceConfigConflict(String),
|
||||
#[error("Runtime binding conflict: {0}")]
|
||||
RuntimeBindingConflict(String),
|
||||
#[error("Runtime binding revision conflict: expected {expected:?}, current {actual:?}")]
|
||||
RuntimeBindingRevisionConflict {
|
||||
expected: Option<u64>,
|
||||
actual: Option<u64>,
|
||||
},
|
||||
#[error("Runtime public key fingerprint is already bound in this Workspace: {fingerprint}")]
|
||||
RuntimeBindingFingerprintConflict { fingerprint: String },
|
||||
#[error("Runtime binding was not found for {runtime_id}")]
|
||||
RuntimeBindingNotFound { runtime_id: String },
|
||||
#[error("Repository conflict: {0}")]
|
||||
RepositoryConflict(String),
|
||||
#[error("Registry inconsistency: {0}")]
|
||||
|
||||
@@ -324,6 +324,7 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
||||
base_url,
|
||||
public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
revoked_at: None,
|
||||
@@ -859,6 +860,7 @@ mod tests {
|
||||
base_url: "http://127.0.0.1:18080".to_string(),
|
||||
public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "2026-07-26T00:00:00Z".to_string(),
|
||||
updated_at: "2026-07-26T00:00:00Z".to_string(),
|
||||
revoked_at: None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::sync::{Arc, Mutex, RwLock, Weak};
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
@@ -74,11 +74,13 @@ use workspace_api::{
|
||||
PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse,
|
||||
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest,
|
||||
PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
|
||||
RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse,
|
||||
RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor,
|
||||
RotateRepositorySshCredentialRequest, RuntimeConnectionTestFailureKind,
|
||||
RuntimeConnectionTestResponse, RuntimeConnectionTestStatus, RuntimeManagementSummary,
|
||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||
PutRuntimeTrustKeyRequest, RepositoryAccessProjection, RepositoryDetailResponse,
|
||||
RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
|
||||
RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest,
|
||||
RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus,
|
||||
RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry,
|
||||
RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyState,
|
||||
RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||
UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse,
|
||||
WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
|
||||
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
||||
@@ -90,8 +92,8 @@ use workspace_api::{
|
||||
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceExtensionPointState,
|
||||
WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse, WorkspaceMetadataSettingsResponse,
|
||||
WorkspacePermissionSummary, WorkspaceRepositoryRecord, WorkspaceResponse,
|
||||
WorkspaceRuntimeResource, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
|
||||
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||
WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceSummary,
|
||||
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
@@ -153,7 +155,7 @@ use crate::store::{
|
||||
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
|
||||
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
|
||||
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
||||
WorkspaceResourceKind,
|
||||
WorkspaceResourceKind, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord,
|
||||
};
|
||||
use crate::workdir_removal::{
|
||||
WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation,
|
||||
@@ -585,6 +587,7 @@ pub struct WorkspaceApi {
|
||||
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
|
||||
authority: SqliteWorkspaceAuthority,
|
||||
runtime: Arc<RuntimeRegistry>,
|
||||
runtime_binding_expectations: Arc<RwLock<HashMap<(String, String), WorkspaceRuntimeBinding>>>,
|
||||
companion: Arc<CompanionConsole>,
|
||||
orchestrator_spawn_lock: Arc<std::sync::Mutex<()>>,
|
||||
orchestrator_attention_fingerprint: Arc<Mutex<Option<String>>>,
|
||||
@@ -1575,6 +1578,7 @@ impl WorkspaceApi {
|
||||
base_url: "in-process://embedded".to_owned(),
|
||||
public_key: embedded_identity.public_key.clone(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: config.workspace_created_at.clone(),
|
||||
updated_at: config.workspace_created_at.clone(),
|
||||
revoked_at: None,
|
||||
@@ -1614,18 +1618,22 @@ impl WorkspaceApi {
|
||||
.then(|| (source.runtime_id.clone(), source.base_url.clone()))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let expected_runtime_bindings = Arc::new(
|
||||
store
|
||||
.list_workspace_runtime_bindings(&config.workspace_id, false)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID)
|
||||
.filter(|binding| {
|
||||
configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url)
|
||||
})
|
||||
.map(|binding| (binding.runtime_id.clone(), binding))
|
||||
.collect::<HashMap<_, _>>(),
|
||||
);
|
||||
let expected_runtime_bindings = store
|
||||
.list_workspace_runtime_bindings(&config.workspace_id, false)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID)
|
||||
.filter(|binding| {
|
||||
configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url)
|
||||
})
|
||||
.map(|binding| {
|
||||
(
|
||||
(binding.workspace_id.clone(), binding.runtime_id.clone()),
|
||||
binding,
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let workspace_id = config.workspace_id.clone();
|
||||
let api = Self::new_with_execution_backend_and_broker(
|
||||
config,
|
||||
store,
|
||||
@@ -1634,15 +1642,34 @@ impl WorkspaceApi {
|
||||
Some(worker_remove_dispatcher),
|
||||
)
|
||||
.await?;
|
||||
*api.runtime_binding_expectations
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = expected_runtime_bindings;
|
||||
let runtime_binding_expectations = Arc::clone(&api.runtime_binding_expectations);
|
||||
api.runtime.set_runtime_binding_gate(move |runtime_id| {
|
||||
expected_runtime_bindings
|
||||
.get(runtime_id)
|
||||
runtime_binding_expectations
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&(workspace_id.clone(), runtime_id.to_string()))
|
||||
.is_some_and(|expected| {
|
||||
runtime_binding_store
|
||||
.workspace_runtime_binding_matches(expected)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
});
|
||||
let active_expectations = api
|
||||
.runtime_binding_expectations
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for source in &api.config.remote_runtime_sources {
|
||||
if !active_expectations
|
||||
.contains_key(&(api.config.workspace_id.clone(), source.runtime_id.clone()))
|
||||
{
|
||||
api.runtime_subscription_broker
|
||||
.unregister_runtime(&source.runtime_id);
|
||||
}
|
||||
}
|
||||
drop(active_expectations);
|
||||
Ok(api)
|
||||
}
|
||||
|
||||
@@ -1748,6 +1775,7 @@ impl WorkspaceApi {
|
||||
config,
|
||||
store,
|
||||
runtime,
|
||||
runtime_binding_expectations: Arc::new(RwLock::new(HashMap::new())),
|
||||
companion,
|
||||
orchestrator_spawn_lock: Arc::new(std::sync::Mutex::new(())),
|
||||
orchestrator_attention_fingerprint: Arc::new(Mutex::new(None)),
|
||||
@@ -2648,7 +2676,11 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}",
|
||||
delete(scoped_delete_remote_runtime),
|
||||
get(scoped_get_runtime_detail).delete(scoped_delete_remote_runtime),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
|
||||
put(scoped_put_runtime_trust_key).delete(scoped_revoke_runtime_trust_key),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests",
|
||||
@@ -10956,11 +10988,225 @@ async fn scoped_create_remote_runtime(
|
||||
create_remote_runtime(State(api), Json(request)).await
|
||||
}
|
||||
|
||||
async fn scoped_get_runtime_detail(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
) -> ApiResult<Json<WorkspaceRuntimeDetail>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let workspace = api
|
||||
.store
|
||||
.get_workspace(&path.workspace_id)
|
||||
.await?
|
||||
.ok_or(Error::WorkspaceIdMismatch)?;
|
||||
let is_owner = workspace.owner_account_id == actor.account_id;
|
||||
Ok(Json(
|
||||
workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, is_owner).await?,
|
||||
))
|
||||
}
|
||||
|
||||
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?;
|
||||
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,
|
||||
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());
|
||||
}
|
||||
};
|
||||
api.runtime_binding_expectations
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert(
|
||||
(path.workspace_id.clone(), path.runtime_id.clone()),
|
||||
binding,
|
||||
);
|
||||
if let Some(source) = source {
|
||||
api.runtime_subscription_broker
|
||||
.register_remote_runtime(source);
|
||||
}
|
||||
Ok(
|
||||
Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn scoped_revoke_runtime_trust_key(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
Json(request): Json<RevokeRuntimeTrustKeyRequest>,
|
||||
) -> 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 == 0 {
|
||||
return Err(settings_bad_request(
|
||||
"invalid_runtime_binding_revision",
|
||||
"expected_revision must be greater than zero",
|
||||
));
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let mutation = api
|
||||
.store
|
||||
.revoke_workspace_runtime_binding_key(
|
||||
&path.workspace_id,
|
||||
&path.runtime_id,
|
||||
request.expected_revision,
|
||||
&actor_account_id,
|
||||
&now,
|
||||
)
|
||||
.await;
|
||||
let _ = 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());
|
||||
}
|
||||
};
|
||||
api.runtime_binding_expectations
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.remove(&(path.workspace_id.clone(), path.runtime_id.clone()));
|
||||
api.runtime_subscription_broker
|
||||
.unregister_runtime(&path.runtime_id);
|
||||
Ok(
|
||||
Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn runtime_trust_conflict_response(
|
||||
api: &WorkspaceApi,
|
||||
path: &ScopedRuntimePath,
|
||||
error: &Error,
|
||||
) -> Option<Response> {
|
||||
let kind = match error {
|
||||
Error::RuntimeBindingRevisionConflict { .. } => RuntimeTrustConflictKind::StaleRevision,
|
||||
Error::RuntimeBindingFingerprintConflict { .. } => {
|
||||
RuntimeTrustConflictKind::FingerprintInUse
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let current = api
|
||||
.store
|
||||
.get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
Some(
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(RuntimeTrustConflictResponse {
|
||||
error: kind,
|
||||
message: match kind {
|
||||
RuntimeTrustConflictKind::StaleRevision => {
|
||||
"the Runtime trust binding changed; reload before retrying".to_string()
|
||||
}
|
||||
RuntimeTrustConflictKind::FingerprintInUse => {
|
||||
"the public key is already bound to another Runtime in this Workspace"
|
||||
.to_string()
|
||||
}
|
||||
},
|
||||
current_revision: current.as_ref().map(|binding| binding.binding_revision),
|
||||
current_fingerprint: current
|
||||
.as_ref()
|
||||
.map(|binding| binding.public_key_fingerprint.clone()),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn scoped_delete_remote_runtime(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime removal").await?;
|
||||
delete_remote_runtime(State(api), AxumPath(path.runtime_id)).await
|
||||
}
|
||||
|
||||
@@ -12211,6 +12457,7 @@ async fn get_workspace(
|
||||
permissions: WorkspacePermissionSummary {
|
||||
manage_repositories: is_owner,
|
||||
manage_secrets: is_owner,
|
||||
manage_runtimes: is_owner,
|
||||
},
|
||||
extension_points: WorkspaceExtensionPoints {
|
||||
store: "sqlite".to_string(),
|
||||
@@ -12451,7 +12698,7 @@ async fn create_remote_runtime(
|
||||
}
|
||||
Err(settings_bad_request(
|
||||
"runtime_public_key_required",
|
||||
"remote Runtime registration requires an authenticated public key; use `yoi-server trust-runtime add` until the Workspace Runtime key API is available",
|
||||
"remote Runtime registration requires an authenticated public key; configure it from the Runtime detail page after the Runtime endpoint is registered",
|
||||
))
|
||||
}
|
||||
|
||||
@@ -12469,8 +12716,13 @@ async fn delete_remote_runtime(
|
||||
.store
|
||||
.get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
|
||||
.await?
|
||||
.filter(|binding| binding.revoked_at.is_none())
|
||||
.ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?;
|
||||
if binding.revoked_at.is_none() {
|
||||
return Err(Error::RuntimeBindingConflict(
|
||||
"runtime trust is still active; revoke this Workspace's trust key with an expected revision before removing the inactive registration".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
match api
|
||||
.runtime
|
||||
.unregister_if_idle(&runtime_id, api.config.max_records.min(200))
|
||||
@@ -12499,14 +12751,6 @@ async fn delete_remote_runtime(
|
||||
));
|
||||
}
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if !api
|
||||
.store
|
||||
.revoke_workspace_runtime_binding_record(&binding.workspace_id, &binding.runtime_id, &now)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::UnknownRuntime(runtime_id).into());
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -14488,6 +14732,133 @@ async fn workspace_runtime_resources_response(
|
||||
})
|
||||
}
|
||||
|
||||
async fn workspace_runtime_detail(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
include_public_key: bool,
|
||||
) -> ApiResult<WorkspaceRuntimeDetail> {
|
||||
let binding = api
|
||||
.store
|
||||
.get_workspace_runtime_binding(workspace_id, runtime_id)
|
||||
.await?;
|
||||
let mut resource = workspace_runtime_resources_response(api, workspace_id)
|
||||
.await?
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|resource| resource.runtime.runtime_id == runtime_id);
|
||||
if resource.is_none() {
|
||||
resource = binding.as_ref().map(|binding| WorkspaceRuntimeResource {
|
||||
runtime: workspace_api::RuntimeSummary {
|
||||
runtime_id: binding.runtime_id.clone(),
|
||||
label: binding.display_name.clone(),
|
||||
kind: "remote_http".to_string(),
|
||||
status: "unavailable".to_string(),
|
||||
source: workspace_api::RuntimeSourceSummary {
|
||||
kind: workspace_api::RuntimeSourceKind::RemoteHttp,
|
||||
status: workspace_api::RuntimeSourceStatus::Reserved,
|
||||
identity_authority:
|
||||
workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration,
|
||||
note: "The Runtime trust binding is not active in the Runtime registry."
|
||||
.to_string(),
|
||||
},
|
||||
host_ids: Vec::new(),
|
||||
worker_creation_available: false,
|
||||
os: String::new(),
|
||||
arch: String::new(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
management: RuntimeManagementSummary {
|
||||
built_in: false,
|
||||
config_managed: true,
|
||||
removable: false,
|
||||
endpoint_configured: !binding.base_url.trim().is_empty(),
|
||||
token_ref_configured: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
let mut resource = resource.ok_or_else(|| Error::UnknownRuntime(runtime_id.to_string()))?;
|
||||
if let Some(binding) = &binding {
|
||||
resource.management.config_managed = true;
|
||||
resource.management.endpoint_configured = !binding.base_url.trim().is_empty();
|
||||
}
|
||||
let endpoint = binding
|
||||
.as_ref()
|
||||
.map(|binding| binding.base_url.clone())
|
||||
.or_else(|| {
|
||||
api.config
|
||||
.remote_runtime_sources
|
||||
.iter()
|
||||
.find(|source| {
|
||||
source.runtime_id == runtime_id
|
||||
&& source.workspace_id.as_deref() == Some(workspace_id)
|
||||
})
|
||||
.map(|source| source.base_url.clone())
|
||||
});
|
||||
let trust_key = binding.as_ref().map_or(
|
||||
RuntimeTrustKeyState {
|
||||
status: RuntimeTrustKeyStatus::Unconfigured,
|
||||
public_key: None,
|
||||
fingerprint: None,
|
||||
revision: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
revoked_at: None,
|
||||
},
|
||||
|binding| RuntimeTrustKeyState {
|
||||
status: if binding.revoked_at.is_some() {
|
||||
RuntimeTrustKeyStatus::Revoked
|
||||
} else {
|
||||
RuntimeTrustKeyStatus::Active
|
||||
},
|
||||
public_key: include_public_key.then(|| binding.public_key.clone()),
|
||||
fingerprint: Some(binding.public_key_fingerprint.clone()),
|
||||
revision: Some(binding.binding_revision),
|
||||
created_at: Some(binding.created_at.clone()),
|
||||
updated_at: Some(binding.updated_at.clone()),
|
||||
revoked_at: binding.revoked_at.clone(),
|
||||
},
|
||||
);
|
||||
let recent_audit = api
|
||||
.store
|
||||
.list_workspace_runtime_binding_audit(workspace_id, runtime_id, 20)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(project_runtime_trust_audit)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(WorkspaceRuntimeDetail {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
runtime: resource,
|
||||
endpoint,
|
||||
trust_key,
|
||||
recent_audit,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_runtime_trust_audit(
|
||||
record: WorkspaceRuntimeBindingAuditRecord,
|
||||
) -> Result<RuntimeTrustAuditEntry> {
|
||||
let action = match record.action.as_str() {
|
||||
"created" => RuntimeTrustAuditAction::Created,
|
||||
"replaced" => RuntimeTrustAuditAction::Replaced,
|
||||
"reactivated" => RuntimeTrustAuditAction::Reactivated,
|
||||
"revoked" => RuntimeTrustAuditAction::Revoked,
|
||||
other => {
|
||||
return Err(Error::Store(format!(
|
||||
"unsupported Runtime trust audit action {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(RuntimeTrustAuditEntry {
|
||||
action,
|
||||
actor_account_id: record.actor_account_id,
|
||||
old_fingerprint: record.old_fingerprint,
|
||||
new_fingerprint: record.new_fingerprint,
|
||||
revision: record.binding_revision,
|
||||
at: record.at,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> {
|
||||
validate_public_runtime_id(request.runtime_id.trim())?;
|
||||
let endpoint = request.endpoint.trim();
|
||||
@@ -16040,6 +16411,8 @@ impl IntoResponse for ApiError {
|
||||
| Error::WorkdirAttachmentConflict(_)
|
||||
| Error::WorkspaceConfigConflict(_)
|
||||
| Error::RuntimeBindingConflict(_)
|
||||
| Error::RuntimeBindingRevisionConflict { .. }
|
||||
| Error::RuntimeBindingFingerprintConflict { .. }
|
||||
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
|
||||
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
||||
@@ -16068,6 +16441,7 @@ impl IntoResponse for ApiError {
|
||||
| Error::UnknownRuntime(_)
|
||||
| Error::UnknownWorker { .. }
|
||||
| Error::UnknownRepository(_)
|
||||
| Error::RuntimeBindingNotFound { .. }
|
||||
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => {
|
||||
StatusCode::NOT_FOUND
|
||||
@@ -16573,6 +16947,7 @@ mod tests {
|
||||
base_url: "https://runtime.test".to_owned(),
|
||||
public_key: identity.public_key.clone(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||
revoked_at: None,
|
||||
@@ -22068,6 +22443,166 @@ mod tests {
|
||||
assert_eq!(detail.provenance.id, "workspace:triage-errors");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_trust_management_is_owner_only_revisioned_and_redacted() {
|
||||
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(),
|
||||
account_id: owner_account_id.clone(),
|
||||
handle: "owner".to_string(),
|
||||
display_name: "Owner".to_string(),
|
||||
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(),
|
||||
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 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,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
revoked_at: None,
|
||||
},
|
||||
None,
|
||||
&owner_account_id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let Json(owner_detail) = scoped_get_runtime_detail(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedRuntimePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
runtime_id: "runtime-a".to_string(),
|
||||
}),
|
||||
Extension(owner.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(owner_detail.trust_key.public_key.is_some());
|
||||
assert_eq!(owner_detail.trust_key.revision, Some(1));
|
||||
let Json(reader_detail) = scoped_get_runtime_detail(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedRuntimePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
runtime_id: "runtime-a".to_string(),
|
||||
}),
|
||||
Extension(non_owner.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(reader_detail.trust_key.public_key.is_none());
|
||||
assert!(reader_detail.trust_key.fingerprint.is_some());
|
||||
|
||||
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));
|
||||
|
||||
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(),
|
||||
}),
|
||||
Extension(non_owner),
|
||||
Json(RevokeRuntimeTrustKeyRequest {
|
||||
expected_revision: 2,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let revoked = scoped_revoke_runtime_trust_key(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedRuntimePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
runtime_id: "runtime-a".to_string(),
|
||||
}),
|
||||
Extension(owner),
|
||||
Json(RevokeRuntimeTrustKeyRequest {
|
||||
expected_revision: 2,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(revoked.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!(binding.revoked_at.is_some());
|
||||
assert!(
|
||||
!api.runtime_binding_expectations
|
||||
.read()
|
||||
.unwrap()
|
||||
.contains_key(&(TEST_WORKSPACE_ID.to_string(), "runtime-a".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_secret_management_is_owner_only() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -22115,6 +22650,16 @@ mod tests {
|
||||
test_api_with_recording_backend(workspace_root).await.0
|
||||
}
|
||||
|
||||
fn test_owner_actor() -> RequestActor {
|
||||
RequestActor {
|
||||
user_id: "owner-user".to_string(),
|
||||
account_id: format!("account-{TEST_WORKSPACE_ID}"),
|
||||
handle: "owner".to_string(),
|
||||
display_name: "Owner".to_string(),
|
||||
auth_method: ActorAuthMethod::BrowserSession,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_repository_id(api: &WorkspaceApi) -> String {
|
||||
api.store
|
||||
.get_repository_by_key(TEST_WORKSPACE_ID, "test-repository")
|
||||
@@ -22602,6 +23147,7 @@ mod tests {
|
||||
base_url: "https://runtime.invalid".to_string(),
|
||||
public_key: identity.public_key.clone(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "2026-08-11T00:00:00Z".to_string(),
|
||||
updated_at: "2026-08-11T00:00:00Z".to_string(),
|
||||
revoked_at: None,
|
||||
@@ -24088,6 +24634,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
revoked_at: None,
|
||||
@@ -25205,6 +25752,7 @@ mod tests {
|
||||
base_url: "https://runtime.example.invalid".to_string(),
|
||||
public_key: identity.public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
revoked_at: None,
|
||||
@@ -25222,7 +25770,7 @@ mod tests {
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let app = build_inner_router(api);
|
||||
let app = build_inner_router(api.clone()).layer(Extension(test_owner_actor()));
|
||||
let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes");
|
||||
|
||||
let initial = get_json(app.clone(), &runtimes_uri).await;
|
||||
@@ -25303,6 +25851,16 @@ mod tests {
|
||||
.expect("team runtime launch option");
|
||||
assert_eq!(team_runtime["working_directory_required"], true);
|
||||
|
||||
api.store
|
||||
.revoke_workspace_runtime_binding_key(
|
||||
TEST_WORKSPACE_ID,
|
||||
"team-runtime",
|
||||
1,
|
||||
&format!("account-{TEST_WORKSPACE_ID}"),
|
||||
&Utc::now().to_rfc3339(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let deleted = request_json(
|
||||
app.clone(),
|
||||
"DELETE",
|
||||
@@ -25354,6 +25912,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
revoked_at: None,
|
||||
@@ -25370,7 +25929,17 @@ mod tests {
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let app = build_inner_router(api);
|
||||
api.store
|
||||
.revoke_workspace_runtime_binding_key(
|
||||
TEST_WORKSPACE_ID,
|
||||
"busy-runtime",
|
||||
1,
|
||||
&format!("account-{TEST_WORKSPACE_ID}"),
|
||||
&Utc::now().to_rfc3339(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_inner_router(api).layer(Extension(test_owner_actor()));
|
||||
let workers = get_json(app.clone(), "/api/workers").await;
|
||||
assert!(
|
||||
workers["items"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user