fix: preserve credential candidates across Workdir retries

This commit is contained in:
2026-09-13 01:24:26 +09:00
7 changed files with 790 additions and 67 deletions
@@ -628,6 +628,42 @@ CREATE TABLE workdir_create_operations (
PRIMARY KEY (workspace_id, operation_id),
UNIQUE (workspace_id, working_directory_id)
);
CREATE TABLE workdir_create_credential_candidates (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2),
role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')),
credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128),
credential_revision INTEGER NOT NULL CHECK (credential_revision > 0),
PRIMARY KEY (workspace_id, operation_id, ordinal),
UNIQUE (workspace_id, operation_id, role),
UNIQUE (workspace_id, operation_id, credential_id),
FOREIGN KEY (workspace_id, operation_id)
REFERENCES workdir_create_operations(workspace_id, operation_id)
ON DELETE CASCADE
);
CREATE INDEX idx_workdir_create_credential_candidates_revision
ON workdir_create_credential_candidates(
workspace_id, credential_id, credential_revision
);
CREATE TABLE workdir_create_credential_revision_retentions (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
credential_id TEXT NOT NULL,
credential_revision INTEGER NOT NULL,
PRIMARY KEY (workspace_id, operation_id, ordinal),
FOREIGN KEY (workspace_id, operation_id, ordinal)
REFERENCES workdir_create_credential_candidates(
workspace_id, operation_id, ordinal
)
ON DELETE CASCADE,
FOREIGN KEY (workspace_id, credential_id, credential_revision)
REFERENCES repository_ssh_credential_revisions(
workspace_id, credential_id, revision
)
ON DELETE RESTRICT
);
CREATE TABLE "workdir_registry" (
workspace_id TEXT NOT NULL,
workdir_id TEXT NOT NULL,
@@ -762,6 +762,20 @@ impl RepositorySecretService {
"credential `{credential_id}` revision changed"
)));
}
let retained_by_workdir_create: bool = tx.query_row(
r#"SELECT EXISTS(
SELECT 1
FROM workdir_create_credential_revision_retentions
WHERE workspace_id = ?1 AND credential_id = ?2
)"#,
params![workspace_id, credential_id],
|row| row.get(0),
)?;
if retained_by_workdir_create {
return Err(Error::RepositoryConflict(format!(
"credential `{credential_id}` is retained by a retryable Workdir create operation"
)));
}
insert_audit(&tx, workspace_id, "credential_deleted", &credential_id, current.current_revision, actor_account_id, &now)?;
let deleted = tx.execute(
"DELETE FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2 AND current_revision = ?3",
@@ -2442,6 +2456,114 @@ mod tests {
);
}
#[test]
fn retryable_workdir_create_retains_candidate_revision_until_success() {
let (_dir, store, service) = test_service();
let (private_key, _) = test_private_key(13);
service
.create_credential(
"workspace-a",
CreateRepositorySshCredentialRequest {
operation_id: "create-retained".to_string(),
credential_id: "retained-deploy".to_string(),
name: "Retained deploy".to_string(),
private_key,
passphrase: None,
},
"owner-a",
)
.unwrap();
let operation = crate::store::WorkdirCreateOperationRecord {
workspace_id: "workspace-a".to_string(),
operation_id: "create-workdir-retained".to_string(),
request_fingerprint: "sha256:request".to_string(),
repository_id: "repo-a".to_string(),
selector: Some("develop".to_string()),
requested_runtime_id: Some("runtime-a".to_string()),
resolved_runtime_id: "runtime-a".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
source_kind: Some("ssh".to_string()),
source_uri: Some("ssh://git@example.test/org/main.git".to_string()),
source_revision: Some(1),
source_fingerprint: Some("sha256:source".to_string()),
credential_id: None,
credential_revision: None,
host_trust_id: None,
host_trust_revision: None,
repository_access_mode: None,
credential_candidates: Vec::new(),
working_directory_id: "workdir-retained".to_string(),
state: "pending".to_string(),
failure: None,
created_at: "2026-08-24T00:00:00Z".to_string(),
updated_at: "2026-08-24T00:00:00Z".to_string(),
};
store.reserve_workdir_create_operation(&operation).unwrap();
let candidates = vec![crate::store::WorkdirCreateCredentialCandidate {
role: crate::store::WorkdirCreateCredentialCandidateRole::Primary,
credential_id: "retained-deploy".to_string(),
credential_revision: 1,
}];
store
.bind_workdir_create_repository_access(
"workspace-a",
"create-workdir-retained",
"sha256:request",
"retained-deploy",
1,
"host-a",
1,
"read_only",
&candidates,
"2026-08-24T00:00:01Z",
)
.unwrap();
let projection = RepositoryAccessProjection {
workspace_id: "workspace-a".to_string(),
config_revision: 1,
projection_digest: "sha256:empty".to_string(),
bindings: Vec::new(),
};
let retained = service
.delete_credential(
"workspace-a",
"retained-deploy",
DeleteRepositorySshCredentialRequest {
operation_id: "delete-retained".to_string(),
expected_revision: 1,
},
"owner-a",
&projection,
)
.unwrap_err();
assert!(matches!(retained, Error::RepositoryConflict(_)));
store
.finish_workdir_create_operation(
"workspace-a",
"create-workdir-retained",
"sha256:request",
true,
None,
"2026-08-24T00:00:02Z",
)
.unwrap();
service
.delete_credential(
"workspace-a",
"retained-deploy",
DeleteRepositorySshCredentialRequest {
operation_id: "delete-released".to_string(),
expected_revision: 1,
},
"owner-a",
&projection,
)
.unwrap();
}
#[test]
fn referenced_resources_cannot_be_deleted() {
let (_dir, _store, service) = test_service();
+213 -15
View File
@@ -172,7 +172,8 @@ use crate::store::{
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryInsertOutcome,
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateCredentialCandidate,
WorkdirCreateCredentialCandidateRole, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
WorkspaceResourceKind, WorkspaceRuntimeAuthenticationMode as StoredRuntimeAuthenticationMode,
WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord, WorkspaceRuntimeBindingMutation,
@@ -10917,6 +10918,7 @@ async fn create_workspace_working_directory(
host_trust_id: None,
host_trust_revision: None,
repository_access_mode: None,
credential_candidates: Vec::new(),
working_directory_id: next_backend_workdir_id(&request.repository_key),
state: "pending".to_string(),
failure: None,
@@ -17624,10 +17626,10 @@ fn authorize_repository_materialization_operation(
) -> ApiResult<()> {
let context = if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh {
if let (
Some(credential_id),
Some(credential_revision),
Some(host_trust_id),
Some(host_trust_revision),
Some(_credential_id),
Some(_credential_revision),
Some(_host_trust_id),
Some(_host_trust_revision),
Some(access_mode),
) = (
operation.credential_id.as_deref(),
@@ -17636,16 +17638,7 @@ fn authorize_repository_materialization_operation(
operation.host_trust_revision,
operation.repository_access_mode.as_deref(),
) {
let lease = api
.repository_secrets
.lease_ssh_materialization_access_revision(
&api.config.workspace_id,
credential_id,
credential_revision,
host_trust_id,
host_trust_revision,
)?;
let leases = repository_ssh_lease_candidates(api, lease)?;
let leases = repository_ssh_lease_candidates_from_operation(api, operation, request)?;
let primary_lease = leases.first().ok_or_else(|| {
settings_bad_request(
"working_directory_repository_access_invalid",
@@ -17766,6 +17759,20 @@ fn authorize_repository_materialization_operation(
"Repository SSH access has no credential candidates",
)
})?;
let credential_candidates = ssh
.credential_candidates
.iter()
.enumerate()
.map(|(index, candidate)| WorkdirCreateCredentialCandidate {
role: if index == 0 {
WorkdirCreateCredentialCandidateRole::Primary
} else {
WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback
},
credential_id: candidate.credential_id.clone(),
credential_revision: candidate.credential_revision,
})
.collect::<Vec<_>>();
api.config_store.bind_workdir_create_repository_access(
&api.config.workspace_id,
&operation.operation_id,
@@ -17778,6 +17785,7 @@ fn authorize_repository_materialization_operation(
workspace_api::RepositoryAccessMode::ReadOnly => "read_only",
workspace_api::RepositoryAccessMode::ReadWrite => "read_write",
},
&credential_candidates,
&now_registry_timestamp(),
)?;
context
@@ -17814,6 +17822,112 @@ fn authorize_worker_spawn_workdir_materialization(
authorize_repository_materialization(api, runtime_id, operation_id, &projection, request)
}
fn repository_ssh_lease_candidates_from_operation(
api: &WorkspaceApi,
operation: &WorkdirCreateOperationRecord,
request: &WorkingDirectoryRequest,
) -> ApiResult<Vec<crate::repository_access::LeasedRepositorySshAccess>> {
if operation.repository_id != request.repository.id
|| operation.source_kind.as_deref() != Some(request.repository.source.kind.as_str())
|| operation.source_uri.as_deref() != Some(request.repository.source.uri.as_str())
|| operation.source_revision != Some(request.repository.source_revision)
|| operation.source_fingerprint.as_deref()
!= Some(request.repository.source_fingerprint.as_str())
{
return Err(settings_bad_request(
"working_directory_repository_access_snapshot_mismatch",
"persisted Workdir Repository access snapshot does not match the create request",
));
}
let host_trust_id = operation.host_trust_id.as_deref().ok_or_else(|| {
settings_bad_request(
"working_directory_repository_access_snapshot_missing",
"persisted Workdir Repository access snapshot is incomplete",
)
})?;
let host_trust_revision = operation.host_trust_revision.ok_or_else(|| {
settings_bad_request(
"working_directory_repository_access_snapshot_missing",
"persisted Workdir Repository access snapshot is incomplete",
)
})?;
if operation.credential_candidates.is_empty() {
return Err(settings_bad_request(
"working_directory_repository_access_snapshot_missing",
"persisted Workdir credential candidate snapshot is unavailable",
));
}
let primary_credential_id = operation.credential_id.as_deref().ok_or_else(|| {
settings_bad_request(
"working_directory_repository_access_snapshot_missing",
"persisted Workdir primary credential evidence is unavailable",
)
})?;
let primary_credential_revision = operation.credential_revision.ok_or_else(|| {
settings_bad_request(
"working_directory_repository_access_snapshot_missing",
"persisted Workdir primary credential evidence is unavailable",
)
})?;
crate::workdir_create_operations::validate_workdir_create_credential_candidates(
primary_credential_id,
primary_credential_revision,
&operation.credential_candidates,
)
.map_err(|_error| {
settings_bad_request(
"working_directory_repository_access_snapshot_invalid",
"persisted Workdir credential candidate snapshot is invalid",
)
})?;
let _access_mode = operation
.repository_access_mode
.as_deref()
.map(|access_mode| match access_mode {
"read_only" => Ok(workspace_api::RepositoryAccessMode::ReadOnly),
"read_write" => Ok(workspace_api::RepositoryAccessMode::ReadWrite),
_other => Err(settings_bad_request(
"working_directory_repository_access_snapshot_invalid",
"persisted Repository access mode is invalid",
)),
})
.transpose()?
.ok_or_else(|| {
settings_bad_request(
"working_directory_repository_access_snapshot_missing",
"persisted Workdir Repository access mode is unavailable",
)
})?;
let mut leases = Vec::with_capacity(operation.credential_candidates.len());
for candidate in &operation.credential_candidates {
let lease = api
.repository_secrets
.lease_ssh_materialization_access_revision(
&api.config.workspace_id,
&candidate.credential_id,
candidate.credential_revision,
host_trust_id,
host_trust_revision,
)
.map_err(|_| {
settings_bad_request(
"working_directory_repository_access_snapshot_unavailable",
"persisted Workdir credential or host-trust revision is unavailable",
)
})?;
if lease.host_trust_id != host_trust_id || lease.host_trust_revision != host_trust_revision
{
return Err(settings_bad_request(
"working_directory_repository_access_snapshot_mismatch",
"persisted Workdir host-trust snapshot does not match the credential candidate",
));
}
leases.push(lease);
}
Ok(leases)
}
fn authorize_repository_materialization(
api: &WorkspaceApi,
runtime_id: &str,
@@ -19673,6 +19787,89 @@ mod tests {
candidates[1].known_hosts_entry
);
let source = workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/org/repository.git".to_string(),
};
let source_fingerprint = repository_source_fingerprint(&source);
let request = WorkingDirectoryRequest {
repository: worker_runtime::catalog::WorkingDirectoryRepository {
id: "repository-a".to_string(),
provider: "git".to_string(),
source: source.clone(),
source_revision: 1,
source_fingerprint: source_fingerprint.clone(),
selector: None,
},
materializer: Default::default(),
backend_workdir_id: Some("workdir-a".to_string()),
materialization: None,
};
let operation = WorkdirCreateOperationRecord {
workspace_id: api.config.workspace_id.clone(),
operation_id: "retry-workdir-a".to_string(),
request_fingerprint: "sha256:request".to_string(),
repository_id: "repository-a".to_string(),
selector: None,
requested_runtime_id: Some("runtime-1".to_string()),
resolved_runtime_id: "runtime-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
source_kind: Some(source.kind.as_str().to_string()),
source_uri: Some(source.uri.clone()),
source_revision: Some(1),
source_fingerprint: Some(source_fingerprint),
credential_id: Some(candidates[0].credential_id.clone()),
credential_revision: Some(candidates[0].credential_revision),
host_trust_id: Some(candidates[0].host_trust_id.clone()),
host_trust_revision: Some(candidates[0].host_trust_revision),
repository_access_mode: Some("read_only".to_string()),
credential_candidates: candidates
.iter()
.enumerate()
.map(|(index, candidate)| WorkdirCreateCredentialCandidate {
role: if index == 0 {
WorkdirCreateCredentialCandidateRole::Primary
} else {
WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback
},
credential_id: candidate.credential_id.clone(),
credential_revision: candidate.credential_revision,
})
.collect(),
working_directory_id: "workdir-a".to_string(),
state: "failed".to_string(),
failure: Some("runtime unavailable".to_string()),
created_at: "2026-08-24T00:00:00Z".to_string(),
updated_at: "2026-08-24T00:00:01Z".to_string(),
};
let retry_candidates =
repository_ssh_lease_candidates_from_operation(&api, &operation, &request).unwrap();
assert_eq!(retry_candidates.len(), 2);
assert_eq!(
retry_candidates
.iter()
.map(|candidate| (
candidate.credential_id.as_str(),
candidate.credential_revision
))
.collect::<Vec<_>>(),
operation
.credential_candidates
.iter()
.map(|candidate| (
candidate.credential_id.as_str(),
candidate.credential_revision
))
.collect::<Vec<_>>()
);
let mut missing_snapshot = operation.clone();
missing_snapshot.credential_candidates.clear();
assert!(
repository_ssh_lease_candidates_from_operation(&api, &missing_snapshot, &request)
.is_err()
);
let default_binding = workspace_api::RepositorySshAccessBinding {
credential_id: crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
.to_string(),
@@ -28363,6 +28560,7 @@ mod tests {
host_trust_id: None,
host_trust_revision: None,
repository_access_mode: None,
credential_candidates: Vec::new(),
working_directory_id: "workdir-provider-rejection".to_string(),
state: "pending".to_string(),
failure: None,
+132 -3
View File
@@ -18,7 +18,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 58;
const LATEST_SCHEMA_VERSION: i64 = 59;
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
@@ -32,6 +32,8 @@ const LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME: &str =
"convert legacy Server-issued Runtime bindings to Workspace identity";
const REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME: &str =
"remove obsolete Workdir Repository cache generation";
const WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME: &str =
"Workdir create credential candidate snapshots";
const MIGRATIONS: &[Migration] = &[
Migration {
@@ -74,6 +76,11 @@ const MIGRATIONS: &[Migration] = &[
name: REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME,
apply: migrate_workdir_cache_generation_v57_to_v58,
},
Migration {
version: 59,
name: WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME,
apply: migrate_workdir_credential_candidate_snapshots_v58_to_v59,
},
];
#[derive(Clone, Copy)]
@@ -553,6 +560,39 @@ pub struct TicketWorkerAssignmentUpdate {
pub previous: Option<TicketCoderAssignmentRecord>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkdirCreateCredentialCandidateRole {
Primary,
WorkspaceDefaultFallback,
}
impl WorkdirCreateCredentialCandidateRole {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Primary => "primary",
Self::WorkspaceDefaultFallback => "workspace_default_fallback",
}
}
pub(crate) fn parse(value: &str) -> Result<Self> {
match value {
"primary" => Ok(Self::Primary),
"workspace_default_fallback" => Ok(Self::WorkspaceDefaultFallback),
other => Err(Error::Store(format!(
"invalid Workdir create credential candidate role `{other}`"
))),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkdirCreateCredentialCandidate {
pub role: WorkdirCreateCredentialCandidateRole,
pub credential_id: String,
pub credential_revision: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkdirCreateOperationRecord {
pub workspace_id: String,
@@ -573,6 +613,8 @@ pub struct WorkdirCreateOperationRecord {
pub host_trust_id: Option<String>,
pub host_trust_revision: Option<u64>,
pub repository_access_mode: Option<String>,
#[serde(default)]
pub credential_candidates: Vec<WorkdirCreateCredentialCandidate>,
pub working_directory_id: String,
pub state: String,
pub failure: Option<String>,
@@ -9583,6 +9625,56 @@ fn table_columns(conn: &Connection, table_name: &str) -> Result<Vec<String>> {
.map_err(Error::from)
}
fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection) -> Result<()> {
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
tx.execute_batch(
r#"
CREATE TABLE workdir_create_credential_candidates (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2),
role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')),
credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128),
credential_revision INTEGER NOT NULL CHECK (credential_revision > 0),
PRIMARY KEY (workspace_id, operation_id, ordinal),
UNIQUE (workspace_id, operation_id, role),
UNIQUE (workspace_id, operation_id, credential_id),
FOREIGN KEY (workspace_id, operation_id)
REFERENCES workdir_create_operations(workspace_id, operation_id)
ON DELETE CASCADE
);
CREATE INDEX idx_workdir_create_credential_candidates_revision
ON workdir_create_credential_candidates(
workspace_id, credential_id, credential_revision
);
CREATE TABLE workdir_create_credential_revision_retentions (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
credential_id TEXT NOT NULL,
credential_revision INTEGER NOT NULL,
PRIMARY KEY (workspace_id, operation_id, ordinal),
FOREIGN KEY (workspace_id, operation_id, ordinal)
REFERENCES workdir_create_credential_candidates(
workspace_id, operation_id, ordinal
)
ON DELETE CASCADE,
FOREIGN KEY (workspace_id, credential_id, credential_revision)
REFERENCES repository_ssh_credential_revisions(
workspace_id, credential_id, revision
)
ON DELETE RESTRICT
);
"#,
)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![59_i64, WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME],
)?;
tx.commit()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -9651,6 +9743,8 @@ mod tests {
create_latest_workspace_schema(&conn).unwrap();
conn.execute_batch(
r#"
DROP TABLE workdir_create_credential_revision_retentions;
DROP TABLE workdir_create_credential_candidates;
DROP INDEX workspace_signing_identity_audit_workspace_idx;
DROP TABLE workspace_signing_identity_audit;
DROP TABLE workspace_signing_identity_provisioning_operations;
@@ -9792,6 +9886,10 @@ mod tests {
version: 58,
name: REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME.to_string(),
},
WorkspaceSchemaMigrationStep {
version: 59,
name: WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME.to_string(),
},
]
);
@@ -9830,6 +9928,10 @@ mod tests {
58,
REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME.to_string(),
),
(
59,
WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME.to_string(),
),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -9900,7 +10002,7 @@ mod tests {
.iter()
.map(|migration| migration.version)
.collect::<Vec<_>>(),
vec![52, 53, 54, 55, 56, 57, 58]
vec![52, 53, 54, 55, 56, 57, 58, 59]
);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
let conn = Connection::open(&path).unwrap();
@@ -9908,7 +10010,7 @@ mod tests {
current_schema_version(&conn).unwrap(),
LATEST_SCHEMA_VERSION
);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 9);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 10);
}
#[test]
@@ -10696,6 +10798,33 @@ mod tests {
);
}
#[test]
fn schema_v59_adds_workdir_create_credential_candidate_snapshots() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
prepare_schema_v50(&path, Some("workspace-a"));
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
for migration in MIGRATIONS
.iter()
.filter(|migration| migration.version <= 58)
{
(migration.apply)(&conn).unwrap();
}
migrate_workdir_credential_candidate_snapshots_v58_to_v59(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 59);
assert!(table_exists(&conn, "workdir_create_credential_candidates").unwrap());
assert!(table_exists(&conn, "workdir_create_credential_revision_retentions").unwrap());
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get::<_, i64>(0)
})
.unwrap(),
0
);
}
#[test]
fn runtime_binding_key_mutations_are_revisioned_idempotent_and_audited() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
@@ -1,9 +1,15 @@
use rusqlite::{OptionalExtension, TransactionBehavior, params};
use sha2::{Digest, Sha256};
use crate::store::WorkdirCreateOperationRecord;
use crate::store::{
WorkdirCreateCredentialCandidate, WorkdirCreateCredentialCandidateRole,
WorkdirCreateOperationRecord,
};
use crate::{Error, Result, SqliteWorkspaceStore};
const MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES: usize = 2;
const MAX_CREDENTIAL_ID_BYTES: usize = 128;
pub fn selector_for_retry(
explicit_selector: Option<&str>,
persisted_selector: Option<&str>,
@@ -172,10 +178,17 @@ impl SqliteWorkspaceStore {
host_trust_id: &str,
host_trust_revision: u64,
repository_access_mode: &str,
credential_candidates: &[WorkdirCreateCredentialCandidate],
now: &str,
) -> Result<WorkdirCreateOperationRecord> {
validate_workdir_create_credential_candidates(
credential_id,
credential_revision,
credential_candidates,
)?;
self.with_conn_mut(|conn| {
let operation = read_workdir_create_operation(conn, workspace_id, operation_id)?
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let operation = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared before Repository access binding"
@@ -193,6 +206,7 @@ impl SqliteWorkspaceStore {
|| operation.host_trust_revision != Some(host_trust_revision)
|| operation.repository_access_mode.as_deref()
!= Some(repository_access_mode)
|| operation.credential_candidates != credential_candidates
{
return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` Repository access evidence changed"
@@ -200,7 +214,7 @@ impl SqliteWorkspaceStore {
}
return Ok(operation);
}
conn.execute(
let updated = tx.execute(
r#"UPDATE workdir_create_operations
SET credential_id = ?4, credential_revision = ?5,
host_trust_id = ?6, host_trust_revision = ?7,
@@ -223,11 +237,60 @@ impl SqliteWorkspaceStore {
now,
],
)?;
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| {
if updated != 1 {
return Err(Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` changed before Repository access binding"
)));
}
for (ordinal, candidate) in credential_candidates.iter().enumerate() {
tx.execute(
r#"INSERT INTO workdir_create_credential_candidates (
workspace_id, operation_id, ordinal, role,
credential_id, credential_revision
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
params![
workspace_id,
operation_id,
i64::try_from(ordinal).map_err(|_| Error::InvalidInput(
"credential candidate ordinal is out of range".to_string()
))?,
candidate.role.as_str(),
candidate.credential_id,
i64::try_from(candidate.credential_revision).map_err(|_| {
Error::InvalidInput(
"credential candidate revision is out of range".to_string(),
)
})?,
],
)?;
tx.execute(
r#"INSERT INTO workdir_create_credential_revision_retentions (
workspace_id, operation_id, ordinal,
credential_id, credential_revision
) VALUES (?1, ?2, ?3, ?4, ?5)"#,
params![
workspace_id,
operation_id,
i64::try_from(ordinal).map_err(|_| Error::InvalidInput(
"credential candidate ordinal is out of range".to_string()
))?,
candidate.credential_id,
i64::try_from(candidate.credential_revision).map_err(|_| {
Error::InvalidInput(
"credential candidate revision is out of range".to_string(),
)
})?,
],
)?;
}
let bound = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared after Repository access binding"
))
})
})?;
tx.commit()?;
Ok(bound)
})
}
@@ -241,7 +304,8 @@ impl SqliteWorkspaceStore {
updated_at: &str,
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
let changed = conn.execute(
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let changed = tx.execute(
r#"UPDATE workdir_create_operations
SET state = ?1, failure = ?2, updated_at = ?3
WHERE workspace_id = ?4 AND operation_id = ?5
@@ -260,11 +324,21 @@ impl SqliteWorkspaceStore {
"Workdir create operation `{operation_id}` could not be finalized"
)));
}
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| {
if succeeded {
tx.execute(
r#"DELETE FROM workdir_create_credential_revision_retentions
WHERE workspace_id = ?1 AND operation_id = ?2"#,
params![workspace_id, operation_id],
)?;
}
let finished = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared"
))
})
})?;
tx.commit()?;
Ok(finished)
})
}
@@ -282,7 +356,8 @@ fn read_workdir_create_operation(
workspace_id: &str,
operation_id: &str,
) -> Result<Option<WorkdirCreateOperationRecord>> {
conn.query_row(
let mut operation = conn
.query_row(
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, source_kind, source_uri, source_revision,
@@ -313,6 +388,7 @@ fn read_workdir_create_operation(
host_trust_id: row.get(15)?,
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
repository_access_mode: row.get(17)?,
credential_candidates: Vec::new(),
working_directory_id: row.get(18)?,
state: row.get(19)?,
failure: row.get(20)?,
@@ -321,8 +397,116 @@ fn read_workdir_create_operation(
})
},
)
.optional()
.map_err(Error::from)
.optional()?;
if let Some(operation) = operation.as_mut() {
let mut statement = conn.prepare(
r#"SELECT ordinal, role, credential_id, credential_revision
FROM workdir_create_credential_candidates
WHERE workspace_id = ?1 AND operation_id = ?2
ORDER BY ordinal ASC"#,
)?;
let rows = statement.query_map(params![workspace_id, operation_id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)?,
))
})?;
for (expected_ordinal, row) in rows.enumerate() {
let (ordinal, role, credential_id, credential_revision) = row?;
if ordinal
!= i64::try_from(expected_ordinal).map_err(|_| {
Error::Store(
"Workdir create credential candidate ordinal is out of range".to_string(),
)
})?
{
return Err(Error::Store(
"Workdir create credential candidate ordinals are not contiguous".to_string(),
));
}
operation
.credential_candidates
.push(WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::parse(&role)?,
credential_id,
credential_revision: u64::try_from(credential_revision).map_err(|_| {
Error::Store(format!(
"invalid Workdir create credential candidate revision `{credential_revision}`"
))
})?,
});
}
if !operation.credential_candidates.is_empty() {
validate_workdir_create_credential_candidates(
operation.credential_id.as_deref().unwrap_or_default(),
operation.credential_revision.unwrap_or_default(),
&operation.credential_candidates,
)
.map_err(|error| {
Error::Store(format!(
"invalid persisted Workdir create credential snapshot: {error}"
))
})?;
}
}
Ok(operation)
}
pub(crate) fn validate_workdir_create_credential_candidates(
credential_id: &str,
credential_revision: u64,
candidates: &[WorkdirCreateCredentialCandidate],
) -> Result<()> {
if candidates.is_empty() || candidates.len() > MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES {
return Err(Error::InvalidInput(format!(
"Workdir create credential candidates must contain 1..={MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES} entries"
)));
}
if credential_id.is_empty() || credential_id.len() > MAX_CREDENTIAL_ID_BYTES {
return Err(Error::InvalidInput(
"Workdir create credential id is invalid".to_string(),
));
}
if credential_revision == 0 {
return Err(Error::InvalidInput(
"Workdir create credential revision must be greater than zero".to_string(),
));
}
let primary = &candidates[0];
if primary.role != WorkdirCreateCredentialCandidateRole::Primary
|| primary.credential_id != credential_id
|| primary.credential_revision != credential_revision
{
return Err(Error::InvalidInput(
"Workdir create primary credential evidence does not match the ordered candidate snapshot"
.to_string(),
));
}
if candidates.len() == 2
&& candidates[1].role != WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback
{
return Err(Error::InvalidInput(
"Workdir create fallback credential role is invalid".to_string(),
));
}
if candidates.iter().any(|candidate| {
candidate.credential_id.is_empty()
|| candidate.credential_id.len() > MAX_CREDENTIAL_ID_BYTES
|| candidate.credential_revision == 0
}) {
return Err(Error::InvalidInput(
"Workdir create credential candidate identity is invalid".to_string(),
));
}
if candidates.len() == 2 && candidates[0].credential_id == candidates[1].credential_id {
return Err(Error::InvalidInput(
"Workdir create credential candidates contain a duplicate credential id".to_string(),
));
}
Ok(())
}
#[cfg(test)]
@@ -347,7 +531,7 @@ mod tests {
}
#[test]
fn retry_keeps_resolved_config_evidence_and_rejects_changed_input() {
fn retry_keeps_resolved_config_and_credential_candidates_after_fallback_moves() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace".to_string(),
@@ -403,6 +587,7 @@ mod tests {
host_trust_id: None,
host_trust_revision: None,
repository_access_mode: None,
credential_candidates: Vec::new(),
working_directory_id: "wd-1".to_string(),
state: "pending".to_string(),
failure: None,
@@ -413,6 +598,44 @@ mod tests {
store.reserve_workdir_create_operation(&record).unwrap(),
record
);
store
.with_conn_mut(|conn| {
for (credential_id, revision) in
[("credential-1", 3_i64), ("workspace-default-ssh", 7_i64)]
{
conn.execute(
r#"INSERT INTO repository_ssh_credentials (
workspace_id, credential_id, name,
public_key_algorithm, public_key_fingerprint,
current_revision, status, created_at
) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?1, ?2,
'active', '2026-08-24T00:00:00Z')"#,
params![credential_id, revision],
)?;
conn.execute(
r#"INSERT INTO repository_ssh_credential_revisions (
workspace_id, credential_id, revision,
public_key_algorithm, public_key_fingerprint, created_at
) VALUES ('workspace', ?1, ?2, 'ssh-ed25519', ?1,
'2026-08-24T00:00:00Z')"#,
params![credential_id, revision],
)?;
}
Ok(())
})
.unwrap();
let candidates = vec![
WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::Primary,
credential_id: "credential-1".to_string(),
credential_revision: 3,
},
WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback,
credential_id: "workspace-default-ssh".to_string(),
credential_revision: 7,
},
];
let bound = store
.bind_workdir_create_repository_access(
"workspace",
@@ -423,12 +646,22 @@ mod tests {
"trust-1",
5,
"read_only",
&candidates,
"2026-08-24T00:00:01Z",
)
.unwrap();
assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
assert_eq!(bound.credential_revision, Some(3));
assert_eq!(bound.host_trust_revision, Some(5));
assert_eq!(bound.credential_candidates, candidates);
let serialized = serde_json::to_string(&bound).unwrap();
assert!(serialized.contains("workspace_default_fallback"));
assert!(!serialized.contains("private_key"));
assert!(!serialized.contains("known_hosts"));
// A concurrent Workspace-default rotation must not replace the fallback
// revision already bound to this operation.
let mut changed_candidates = candidates.clone();
changed_candidates[1].credential_revision = 8;
assert!(
store
.bind_workdir_create_repository_access(
@@ -436,10 +669,11 @@ mod tests {
"call-1",
&record.request_fingerprint,
"credential-1",
4,
3,
"trust-1",
5,
"read_only",
&changed_candidates,
"2026-08-24T00:00:02Z",
)
.is_err()
@@ -475,6 +709,7 @@ mod tests {
.unwrap();
assert_eq!(retry.state, "pending");
assert_eq!(retry.failure, None);
assert_eq!(retry.credential_candidates, candidates);
assert_eq!(
store
.load_workdir_create_operation("workspace", "call-1")
@@ -1098,6 +1098,7 @@ mod tests {
host_trust_id: None,
host_trust_revision: None,
repository_access_mode: None,
credential_candidates: Vec::new(),
working_directory_id: "workdir-a".to_string(),
state: "pending".to_string(),
failure: None,
@@ -55,6 +55,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"typed_ticket_relations",
"typed_ticket_risk_flags",
"typed_tickets",
"workdir_create_credential_candidates",
"workdir_create_credential_revision_retentions",
"workdir_create_operations",
"workdir_registry",
"workdir_removal_operations",