fix: enforce Workspace Runtime binding lifecycle

This commit is contained in:
2026-09-09 13:20:00 +09:00
parent 3df611636b
commit 9d7ddcc04a
2 changed files with 306 additions and 34 deletions
+121 -32
View File
@@ -10331,11 +10331,27 @@ fn working_directory_diagnostics(
.collect() .collect()
} }
async fn require_active_workspace_runtime_binding(
api: &WorkspaceApi,
runtime_id: &str,
) -> ApiResult<()> {
let binding = api
.store
.get_workspace_runtime_binding(&api.config.workspace_id, runtime_id)
.await?
.ok_or_else(|| Error::UnknownRuntime(runtime_id.to_string()))?;
if binding.revoked_at.is_some() {
return Err(Error::UnknownRuntime(runtime_id.to_string()).into());
}
Ok(())
}
async fn scoped_list_runtime_working_directories( async fn scoped_list_runtime_working_directories(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>, AxumPath(path): AxumPath<ScopedRuntimePath>,
) -> ApiResult<Json<BrowserWorkingDirectoryListResponse>> { ) -> ApiResult<Json<BrowserWorkingDirectoryListResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
require_active_workspace_runtime_binding(&api, &path.runtime_id).await?;
let (items, diagnostics) = runtime_working_directory_summaries(&api, &path.runtime_id)?; let (items, diagnostics) = runtime_working_directory_summaries(&api, &path.runtime_id)?;
Ok(Json(BrowserWorkingDirectoryListResponse { Ok(Json(BrowserWorkingDirectoryListResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
@@ -10349,6 +10365,7 @@ async fn scoped_create_runtime_working_directory(
AxumPath(path): AxumPath<ScopedRuntimePath>, AxumPath(path): AxumPath<ScopedRuntimePath>,
Json(request): Json<BrowserWorkingDirectoryCreateRequest>, Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> { ) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
require_active_workspace_runtime_binding(&api, &path.runtime_id).await?;
create_workspace_working_directory( create_workspace_working_directory(
&api, &api,
&path.workspace_id, &path.workspace_id,
@@ -10363,6 +10380,7 @@ async fn scoped_runtime_working_directory_detail(
AxumPath(path): AxumPath<ScopedRuntimeWorkingDirectoryPath>, AxumPath(path): AxumPath<ScopedRuntimeWorkingDirectoryPath>,
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> { ) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
require_active_workspace_runtime_binding(&api, &path.runtime_id).await?;
working_directory_detail_for_runtime(api, &path.runtime_id, &path.working_directory_id) working_directory_detail_for_runtime(api, &path.runtime_id, &path.working_directory_id)
} }
@@ -10374,6 +10392,7 @@ async fn scoped_cleanup_runtime_working_directory(
Json(request): Json<WorkingDirectoryRemovalRequest>, Json(request): Json<WorkingDirectoryRemovalRequest>,
) -> ApiResult<Json<WorkingDirectoryRemovalResponse>> { ) -> ApiResult<Json<WorkingDirectoryRemovalResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
require_active_workspace_runtime_binding(&api, &path.runtime_id).await?;
let registered_runtime = registered_workdir_runtime_id(&api, &path.working_directory_id)?; let registered_runtime = registered_workdir_runtime_id(&api, &path.working_directory_id)?;
if registered_runtime != path.runtime_id { if registered_runtime != path.runtime_id {
return Err(ApiError::from(Error::WorkspacePermissionDenied( return Err(ApiError::from(Error::WorkspacePermissionDenied(
@@ -13587,34 +13606,48 @@ async fn delete_remote_runtime(
) )
.into()); .into());
} }
match api let has_other_active_binding = api
.runtime .store
.unregister_if_idle(&runtime_id, api.config.max_records.min(200)) .has_other_active_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
.map_err(|err| err.into_error())? .await?;
{ if !has_other_active_binding {
RuntimeRegistryUnregisterResult::Removed | RuntimeRegistryUnregisterResult::NotFound => {} match api
RuntimeRegistryUnregisterResult::BlockedByWorkers { .runtime
worker_count, .unregister_if_idle(&runtime_id, api.config.max_records.min(200))
diagnostics, .map_err(|err| err.into_error())?
} => { {
let mut diagnostics = diagnostics; RuntimeRegistryUnregisterResult::Removed
diagnostics.push(settings_diagnostic( | RuntimeRegistryUnregisterResult::NotFound => {}
"remote_runtime_delete_blocked", RuntimeRegistryUnregisterResult::BlockedByWorkers {
DiagnosticSeverity::Error, worker_count,
format!(
"Remote Runtime '{runtime_id}' has {worker_count} active worker(s); stop or move them before deleting it."
),
));
return Err(ApiError::with_diagnostics(
Error::RuntimeOperationFailed {
runtime_id,
code: "remote_runtime_delete_blocked".to_string(),
message: "Remote Runtime has active workers".to_string(),
},
diagnostics, diagnostics,
)); } => {
let mut diagnostics = diagnostics;
diagnostics.push(settings_diagnostic(
"remote_runtime_delete_blocked",
DiagnosticSeverity::Error,
format!(
"Remote Runtime '{runtime_id}' has {worker_count} active worker(s); stop or move them before deleting its final Workspace registration."
),
));
return Err(ApiError::with_diagnostics(
Error::RuntimeOperationFailed {
runtime_id,
code: "remote_runtime_delete_blocked".to_string(),
message: "Remote Runtime has active workers".to_string(),
},
diagnostics,
));
}
} }
} }
if !api
.store
.delete_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
.await?
{
return Err(Error::UnknownRuntime(runtime_id).into());
}
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -16130,16 +16163,20 @@ fn runtime_binding_summary(
binding: &WorkspaceRuntimeBinding, binding: &WorkspaceRuntimeBinding,
verification: Option<&crate::store::WorkspaceRuntimeVerificationEvidence>, verification: Option<&crate::store::WorkspaceRuntimeVerificationEvidence>,
) -> WorkspaceRuntimeBindingSummary { ) -> WorkspaceRuntimeBindingSummary {
let valid_verification = verification.filter(|verification| { let current_verification = verification.filter(|verification| {
verification.state == "verified" verification.binding_revision == binding.binding_revision
&& verification.binding_revision == binding.binding_revision
&& verification.runtime_public_key_fingerprint == binding.public_key_fingerprint && verification.runtime_public_key_fingerprint == binding.public_key_fingerprint
&& verification.workspace_key_id && verification.workspace_key_id
== binding.workspace_key_id.as_deref().unwrap_or_default() == binding.workspace_key_id.as_deref().unwrap_or_default()
}); });
let valid_verification = current_verification.filter(|verification| {
verification.state == "verified" && verification.last_outcome == "verified"
});
let connection_state = match binding.state { let connection_state = match binding.state {
StoredRuntimeBindingState::Revoked => RuntimeConnectionDisplayState::Revoked, StoredRuntimeBindingState::Revoked => RuntimeConnectionDisplayState::Revoked,
_ if verification.is_some_and(|verification| verification.last_outcome != "verified") => { _ if current_verification
.is_some_and(|verification| verification.last_outcome != "verified") =>
{
RuntimeConnectionDisplayState::Unavailable RuntimeConnectionDisplayState::Unavailable
} }
StoredRuntimeBindingState::Verified StoredRuntimeBindingState::Verified
@@ -16161,7 +16198,7 @@ fn runtime_binding_summary(
revision: binding.binding_revision, revision: binding.binding_revision,
workspace_key_id: binding.workspace_key_id.clone(), workspace_key_id: binding.workspace_key_id.clone(),
workspace_key_generation: binding.workspace_key_generation, workspace_key_generation: binding.workspace_key_generation,
verification: verification.and_then(runtime_verification_summary), verification: current_verification.and_then(runtime_verification_summary),
} }
} }
@@ -20957,6 +20994,59 @@ mod tests {
config config
} }
#[tokio::test]
async fn scoped_runtime_workdir_access_requires_workspace_binding() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let response = require_active_workspace_runtime_binding(&api, "unregistered-runtime")
.await
.unwrap_err()
.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn runtime_binding_summary_omits_stale_verification_revision() {
let binding = WorkspaceRuntimeBinding {
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-a".to_string(),
display_name: "Runtime A".to_string(),
base_url: "https://runtime.example.test".to_string(),
public_key: "runtime-public-key".to_string(),
public_key_fingerprint: "sha256:runtime".to_string(),
binding_revision: 2,
state: crate::store::WorkspaceRuntimeBindingState::Revoked,
authentication_mode:
crate::store::WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-a".to_string()),
workspace_key_generation: Some(1),
created_at: "1".to_string(),
updated_at: "2".to_string(),
revoked_at: Some("2".to_string()),
};
let stale = crate::store::WorkspaceRuntimeVerificationEvidence {
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-a".to_string(),
binding_revision: 1,
workspace_key_id: "WK-a".to_string(),
workspace_identity_revision: 1,
workspace_trust_generation: 1,
runtime_public_key_fingerprint: "sha256:runtime".to_string(),
runtime_identity_revision: 1,
challenge_id: "challenge-a".to_string(),
state: "failed".to_string(),
last_outcome: "connectivity_failed".to_string(),
verified_at: None,
checked_at: "1".to_string(),
};
let summary = runtime_binding_summary(&binding, Some(&stale));
assert_eq!(summary.verification, None);
}
#[test] #[test]
fn embedded_runtime_request_source_uses_backend_public_url_audience() { fn embedded_runtime_request_source_uses_backend_public_url_audience() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -27954,9 +28044,8 @@ mod tests {
let persisted = store let persisted = store
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "team-runtime") .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "team-runtime")
.await .await
.unwrap()
.unwrap(); .unwrap();
assert!(persisted.revoked_at.is_some()); assert!(persisted.is_none());
} }
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
+185 -2
View File
@@ -799,6 +799,16 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
workspace_id: &str, workspace_id: &str,
include_revoked: bool, include_revoked: bool,
) -> Result<Vec<WorkspaceRuntimeBinding>>; ) -> Result<Vec<WorkspaceRuntimeBinding>>;
async fn has_other_active_workspace_runtime_binding(
&self,
workspace_id: &str,
runtime_id: &str,
) -> Result<bool>;
async fn delete_workspace_runtime_binding(
&self,
workspace_id: &str,
runtime_id: &str,
) -> Result<bool>;
async fn upsert_workspace_runtime_binding_record( async fn upsert_workspace_runtime_binding_record(
&self, &self,
record: WorkspaceRuntimeBinding, record: WorkspaceRuntimeBinding,
@@ -1860,6 +1870,88 @@ impl SqliteWorkspaceStore {
}) })
} }
pub fn has_other_active_workspace_runtime_binding(
&self,
workspace_id: &str,
runtime_id: &str,
) -> Result<bool> {
validate_identifier("workspace_id", workspace_id)?;
validate_identifier("runtime_id", runtime_id)?;
self.with_conn(|conn| {
conn.query_row(
"SELECT EXISTS(
SELECT 1 FROM workspace_runtime_bindings
WHERE runtime_id = ?1 AND workspace_id <> ?2 AND revoked_at IS NULL
)",
params![runtime_id, workspace_id],
|row| row.get(0),
)
.map_err(Into::into)
})
}
pub fn delete_workspace_runtime_binding(
&self,
workspace_id: &str,
runtime_id: &str,
) -> Result<bool> {
validate_identifier("workspace_id", workspace_id)?;
validate_identifier("runtime_id", runtime_id)?;
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let revoked_at = tx
.query_row(
"SELECT revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2",
params![workspace_id, runtime_id],
|row| row.get::<_, Option<String>>(0),
)
.optional()?;
let Some(revoked_at) = revoked_at else {
tx.commit()?;
return Ok(false);
};
if revoked_at.is_none() {
return Err(crate::Error::RuntimeBindingConflict(format!(
"Runtime binding `{runtime_id}` must be revoked before deletion"
)));
}
let worker_count: i64 = tx.query_row(
"SELECT COUNT(*) FROM worker_registry WHERE workspace_id = ?1 AND runtime_id = ?2",
params![workspace_id, runtime_id],
|row| row.get(0),
)?;
if worker_count > 0 {
return Err(crate::Error::RuntimeBindingConflict(format!(
"Runtime binding `{runtime_id}` still has {worker_count} registered Worker(s) in Workspace `{workspace_id}`"
)));
}
let workdir_count: i64 = tx.query_row(
"SELECT COUNT(*) FROM workdir_registry WHERE workspace_id = ?1 AND runtime_id = ?2",
params![workspace_id, runtime_id],
|row| row.get(0),
)?;
if workdir_count > 0 {
return Err(crate::Error::RuntimeBindingConflict(format!(
"Runtime binding `{runtime_id}` still has {workdir_count} registered Workdir(s) in Workspace `{workspace_id}`"
)));
}
tx.execute(
"DELETE FROM worker_mutation_source_proof_jtis WHERE workspace_id = ?1 AND runtime_id = ?2",
params![workspace_id, runtime_id],
)?;
tx.execute(
"DELETE FROM workspace_runtime_binding_audit WHERE workspace_id = ?1 AND runtime_id = ?2",
params![workspace_id, runtime_id],
)?;
let deleted = tx.execute(
"DELETE FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2",
params![workspace_id, runtime_id],
)?;
tx.commit()?;
Ok(deleted == 1)
})
}
pub fn upsert_workspace_runtime_binding( pub fn upsert_workspace_runtime_binding(
&self, &self,
mut record: WorkspaceRuntimeBinding, mut record: WorkspaceRuntimeBinding,
@@ -1928,6 +2020,10 @@ impl SqliteWorkspaceStore {
], ],
) )
.map_err(map_runtime_binding_write_error)?; .map_err(map_runtime_binding_write_error)?;
tx.execute(
"DELETE FROM workspace_runtime_verifications WHERE workspace_id = ?1 AND runtime_id = ?2",
params![record.workspace_id, record.runtime_id],
)?;
tx.commit()?; tx.commit()?;
return Ok(WorkspaceRuntimeBindingUpsert::Replaced); return Ok(WorkspaceRuntimeBindingUpsert::Replaced);
} }
@@ -1968,14 +2064,22 @@ impl SqliteWorkspaceStore {
validate_identifier("workspace_id", workspace_id)?; validate_identifier("workspace_id", workspace_id)?;
validate_identifier("runtime_id", runtime_id)?; validate_identifier("runtime_id", runtime_id)?;
validate_non_empty("revoked_at", revoked_at)?; validate_non_empty("revoked_at", revoked_at)?;
self.with_conn(|conn| { self.with_conn_mut(|conn| {
let changed = conn.execute( let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let changed = tx.execute(
r#"UPDATE workspace_runtime_bindings r#"UPDATE workspace_runtime_bindings
SET state = 'revoked', revoked_at = ?3, updated_at = ?3, SET state = 'revoked', revoked_at = ?3, updated_at = ?3,
binding_revision = binding_revision + 1 binding_revision = binding_revision + 1
WHERE workspace_id = ?1 AND runtime_id = ?2 AND revoked_at IS NULL"#, WHERE workspace_id = ?1 AND runtime_id = ?2 AND revoked_at IS NULL"#,
params![workspace_id, runtime_id, revoked_at], params![workspace_id, runtime_id, revoked_at],
)?; )?;
if changed > 0 {
tx.execute(
"DELETE FROM workspace_runtime_verifications WHERE workspace_id = ?1 AND runtime_id = ?2",
params![workspace_id, runtime_id],
)?;
}
tx.commit()?;
Ok(changed > 0) Ok(changed > 0)
}) })
} }
@@ -2081,6 +2185,10 @@ impl SqliteWorkspaceStore {
record.updated_at, record.updated_at,
], ],
)?; )?;
tx.execute(
"DELETE FROM workspace_runtime_verifications WHERE workspace_id = ?1 AND runtime_id = ?2",
params![record.workspace_id, record.runtime_id],
)?;
insert_workspace_runtime_binding_audit( insert_workspace_runtime_binding_audit(
&tx, &tx,
&record.workspace_id, &record.workspace_id,
@@ -2211,6 +2319,10 @@ impl SqliteWorkspaceStore {
WHERE workspace_id = ?1 AND runtime_id = ?2"#, WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![workspace_id, runtime_id, revoked_at, next_revision], params![workspace_id, runtime_id, revoked_at, next_revision],
)?; )?;
tx.execute(
"DELETE FROM workspace_runtime_verifications WHERE workspace_id = ?1 AND runtime_id = ?2",
params![workspace_id, runtime_id],
)?;
insert_workspace_runtime_binding_audit( insert_workspace_runtime_binding_audit(
&tx, &tx,
workspace_id, workspace_id,
@@ -3331,6 +3443,26 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
SqliteWorkspaceStore::list_workspace_runtime_bindings(self, workspace_id, include_revoked) SqliteWorkspaceStore::list_workspace_runtime_bindings(self, workspace_id, include_revoked)
} }
async fn has_other_active_workspace_runtime_binding(
&self,
workspace_id: &str,
runtime_id: &str,
) -> Result<bool> {
SqliteWorkspaceStore::has_other_active_workspace_runtime_binding(
self,
workspace_id,
runtime_id,
)
}
async fn delete_workspace_runtime_binding(
&self,
workspace_id: &str,
runtime_id: &str,
) -> Result<bool> {
SqliteWorkspaceStore::delete_workspace_runtime_binding(self, workspace_id, runtime_id)
}
async fn upsert_workspace_runtime_binding_record( async fn upsert_workspace_runtime_binding_record(
&self, &self,
record: WorkspaceRuntimeBinding, record: WorkspaceRuntimeBinding,
@@ -10044,6 +10176,28 @@ mod tests {
.revoked_at .revoked_at
.is_some() .is_some()
); );
assert!(
reopened
.has_other_active_workspace_runtime_binding("workspace-a", "shared")
.unwrap()
);
assert!(
reopened
.delete_workspace_runtime_binding("workspace-a", "shared")
.unwrap()
);
assert!(
reopened
.get_workspace_runtime_binding("workspace-a", "shared")
.unwrap()
.is_none()
);
assert!(
reopened
.get_workspace_runtime_binding("workspace-b", "shared")
.unwrap()
.is_some()
);
} }
#[test] #[test]
@@ -10485,6 +10639,29 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(replaced, WorkspaceRuntimeBindingMutation::Replaced); assert_eq!(replaced, WorkspaceRuntimeBindingMutation::Replaced);
assert_eq!(replaced_binding.binding_revision, 2); assert_eq!(replaced_binding.binding_revision, 2);
store
.record_workspace_runtime_verification_attempt(&WorkspaceRuntimeVerificationEvidence {
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-a".to_string(),
binding_revision: 2,
workspace_key_id: "WK-a".to_string(),
workspace_identity_revision: 1,
workspace_trust_generation: 1,
runtime_public_key_fingerprint: replaced_binding.public_key_fingerprint.clone(),
runtime_identity_revision: 1,
challenge_id: "challenge-a".to_string(),
state: "failed".to_string(),
last_outcome: "connectivity_failed".to_string(),
verified_at: None,
checked_at: "3".to_string(),
})
.unwrap();
assert!(
store
.get_workspace_runtime_verification("workspace-a", "runtime-a")
.unwrap()
.is_some()
);
let (revoked, revoked_binding) = store let (revoked, revoked_binding) = store
.revoke_workspace_runtime_binding_key("workspace-a", "runtime-a", 2, "owner", "4") .revoke_workspace_runtime_binding_key("workspace-a", "runtime-a", 2, "owner", "4")
.unwrap(); .unwrap();
@@ -10492,6 +10669,12 @@ mod tests {
assert_eq!(revoked_binding.binding_revision, 3); assert_eq!(revoked_binding.binding_revision, 3);
assert_eq!(revoked_binding.revoked_at.as_deref(), Some("4")); assert_eq!(revoked_binding.revoked_at.as_deref(), Some("4"));
assert_eq!(revoked_binding.state, WorkspaceRuntimeBindingState::Revoked); assert_eq!(revoked_binding.state, WorkspaceRuntimeBindingState::Revoked);
assert_eq!(
store
.get_workspace_runtime_verification("workspace-a", "runtime-a")
.unwrap(),
None
);
let (reactivated, reactivated_binding) = store let (reactivated, reactivated_binding) = store
.put_workspace_runtime_binding_key( .put_workspace_runtime_binding_key(
binding(second.public_key.clone(), "5"), binding(second.public_key.clone(), "5"),