fix: validate repository access within owner adoption transaction

This commit is contained in:
2026-11-04 20:05:09 +09:00
parent e6c95894c5
commit eb64644c9b
4 changed files with 346 additions and 38 deletions
@@ -529,6 +529,7 @@ fn prepare(
schema, schema,
)?; )?;
crate::config_source::validate_workspace_config_candidate_projections(workspace, &candidate)?; crate::config_source::validate_workspace_config_candidate_projections(workspace, &candidate)?;
crate::repository_access::validate_repository_access_in_transaction(tx, workspace, &candidate)?;
let source = policy_config::source(&candidate.evaluation)?; let source = policy_config::source(&candidate.evaluation)?;
policy_config::validate_source(host, &source, require_subject)?; policy_config::validate_source(host, &source, require_subject)?;
let state = WorkspaceConfigState { let state = WorkspaceConfigState {
@@ -189,24 +189,65 @@ fn project_repository_access_evaluation(
projection_digest: &str, projection_digest: &str,
evaluation: &config_source::EvaluationResult, evaluation: &config_source::EvaluationResult,
) -> Result<RepositoryAccessProjection> { ) -> Result<RepositoryAccessProjection> {
let projection = evaluation.projections.first().ok_or_else(|| { let config = repository_access_source(evaluation)?;
Error::InvalidInput("Workspace config produced no active projection".to_string())
})?;
let config: VirtualWorkspaceConfig = serde_json::from_value(projection.data_json.clone())
.map_err(|error| {
Error::InvalidInput(format!("invalid Repository access config: {error}"))
})?;
if !config.repository_access.is_empty() { if !config.repository_access.is_empty() {
secrets.ensure_workspace_default_credential(workspace_id)?; secrets.ensure_workspace_default_credential(workspace_id)?;
} }
project_repository_access_bindings(
workspace_id,
projection_digest,
config,
|key| store.get_repository_by_key(workspace_id, key),
|id| secrets.get_credential(workspace_id, id, &[]),
|id| secrets.get_host_trust(workspace_id, id, &[]),
)
}
/// Validate against the same catalog transaction that will adopt the source.
/// This reads only public credential/trust metadata: no default key generation,
/// unsealing, filesystem writes or second connection under the writer lock.
/// Both preview and adoption use the owning projection's semantic checks.
pub(crate) fn validate_repository_access_in_transaction(
tx: &rusqlite::Transaction<'_>,
workspace_id: &str,
candidate: &EvaluatedConfigCandidate,
) -> Result<()> {
project_repository_access_bindings(
workspace_id,
&candidate.evaluation.projection_digest,
repository_access_source(&candidate.evaluation)?,
|key| crate::store::read_repository_by_key(tx, workspace_id, key),
|id| read_credential(tx, workspace_id, id),
|id| read_host_trust(tx, workspace_id, id),
)
.map(|_| ())
}
fn repository_access_source(
evaluation: &config_source::EvaluationResult,
) -> Result<VirtualWorkspaceConfig> {
let projection = evaluation.projections.first().ok_or_else(|| {
Error::InvalidInput("Workspace config produced no active projection".to_string())
})?;
serde_json::from_value(projection.data_json.clone())
.map_err(|error| Error::InvalidInput(format!("invalid Repository access config: {error}")))
}
fn project_repository_access_bindings(
workspace_id: &str,
projection_digest: &str,
config: VirtualWorkspaceConfig,
repository: impl Fn(&str) -> Result<Option<crate::store::RepositoryRecord>>,
credential: impl Fn(&str) -> Result<Option<RepositorySshCredential>>,
host_trust: impl Fn(&str) -> Result<Option<RepositorySshHostTrust>>,
) -> Result<RepositoryAccessProjection> {
let mut bindings = Vec::with_capacity(config.repository_access.len()); let mut bindings = Vec::with_capacity(config.repository_access.len());
for (repository_key, access) in config.repository_access { for (repository_key, access) in config.repository_access {
server_api::validate_repository_key(&repository_key) server_api::validate_repository_key(&repository_key)
.map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?; .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?;
validate_identifier("credential_id", &access.ssh.credential)?; validate_identifier("credential_id", &access.ssh.credential)?;
validate_identifier("host_trust_id", &access.ssh.host_trust)?; validate_identifier("host_trust_id", &access.ssh.host_trust)?;
let repository = store let repository = repository(&repository_key)?
.get_repository_by_key(workspace_id, &repository_key)?
.ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?; .ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?;
validate_repository_access_source(&repository_key, &repository.source)?; validate_repository_access_source(&repository_key, &repository.source)?;
if repository.source.kind != server_api::RepositorySourceKind::Ssh { if repository.source.kind != server_api::RepositorySourceKind::Ssh {
@@ -214,28 +255,24 @@ fn project_repository_access_evaluation(
"Repository `{repository_key}` is not an ssh:// Repository" "Repository `{repository_key}` is not an ssh:// Repository"
))); )));
} }
let credential = secrets let credential = credential(&access.ssh.credential)?.ok_or_else(|| {
.get_credential(workspace_id, &access.ssh.credential, &[])? Error::InvalidInput(format!(
.ok_or_else(|| { "unknown Repository SSH credential `{}`",
Error::InvalidInput(format!( access.ssh.credential
"unknown Repository SSH credential `{}`", ))
access.ssh.credential })?;
))
})?;
if credential.status != "active" { if credential.status != "active" {
return Err(Error::InvalidInput(format!( return Err(Error::InvalidInput(format!(
"Repository SSH credential `{}` is not active", "Repository SSH credential `{}` is not active",
access.ssh.credential access.ssh.credential
))); )));
} }
let host_trust = secrets let host_trust = host_trust(&access.ssh.host_trust)?.ok_or_else(|| {
.get_host_trust(workspace_id, &access.ssh.host_trust, &[])? Error::InvalidInput(format!(
.ok_or_else(|| { "unknown Repository SSH host trust `{}`",
Error::InvalidInput(format!( access.ssh.host_trust
"unknown Repository SSH host trust `{}`", ))
access.ssh.host_trust })?;
))
})?;
let (hostname, port) = let (hostname, port) =
repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else( repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else(
|| { || {
@@ -355,6 +355,270 @@ mod configuration_adoption_http_integration {
) )
} }
#[tokio::test]
async fn owner_http_repository_validation_is_read_only_and_precedes_seal() {
let f = Fixture::new().await;
let source = server_api::RepositorySource {
kind: server_api::RepositorySourceKind::Ssh,
uri: "ssh://git@repository.example.test:2222/team/repo.git".into(),
};
f.api
.store
.upsert_repository(&crate::store::RepositoryRecord {
workspace_id: TEST_WORKSPACE_ID.into(),
repository_id: "ssh-repository".into(),
repository_key: "remote".into(),
kind: "git".into(),
provider: None,
source_fingerprint: crate::repository_source::repository_source_fingerprint(
&source,
),
source,
default_ref: None,
observed_status: server_api::RepositoryObservedStatus::Unverified,
observed_at: None,
created_at: "1".into(),
updated_at: "1".into(),
})
.unwrap();
let mut non_ssh = f
.api
.store
.get_repository_by_key(TEST_WORKSPACE_ID, "remote")
.unwrap()
.unwrap();
non_ssh.repository_id = "https-repository".into();
non_ssh.repository_key = "nonssh".into();
non_ssh.source = server_api::RepositorySource {
kind: server_api::RepositorySourceKind::Https,
uri: "https://repository.example.test/team/repo.git".into(),
};
non_ssh.source_fingerprint =
crate::repository_source::repository_source_fingerprint(&non_ssh.source);
f.api.store.upsert_repository(&non_ssh).unwrap();
for id in ["deploy", "revoked"] {
f.api
.repository_secrets
.generate_credential(
TEST_WORKSPACE_ID,
GenerateRepositorySshCredentialRequest {
operation_id: format!("generate-{id}"),
credential_id: id.into(),
name: id.into(),
},
&f.owner_account,
)
.unwrap();
}
f.api.config_store.with_conn(|conn| {
conn.execute("UPDATE repository_ssh_credentials SET status='revoked' WHERE workspace_id=?1 AND credential_id='revoked'", [TEST_WORKSPACE_ID])?;
Ok(())
}).unwrap();
let public_key =
ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[37; 32]))
.public_key()
.to_openssh()
.unwrap();
for (id, hostname, port) in [
("matching", "repository.example.test", 2222),
("wrong-host", "other.example.test", 2222),
("wrong-port", "repository.example.test", 22),
] {
f.api
.repository_secrets
.put_host_trust(
TEST_WORKSPACE_ID,
PutRepositorySshHostTrustRequest {
operation_id: format!("trust-{id}"),
host_trust_id: id.into(),
hostname: hostname.into(),
port,
host_key: public_key.clone(),
expected_fingerprint: None,
},
&f.owner_account,
)
.unwrap();
}
let secret_counts = || {
f.api
.config_store
.with_conn(|conn| {
[
"repository_ssh_credentials",
"repository_ssh_credential_keys",
"repository_ssh_host_trusts",
"repository_ssh_host_trust_keys",
"repository_secret_operations",
"repository_secret_audit_events",
"server_secret_objects",
]
.into_iter()
.map(|table| {
conn.query_row(
&format!("SELECT COUNT(*) FROM {table} WHERE workspace_id=?1"),
[TEST_WORKSPACE_ID],
|r| r.get::<_, i64>(0),
)
.map_err(Error::from)
})
.collect::<Result<Vec<_>>>()
})
.unwrap()
};
let secrets_before = secret_counts();
let content = |repo: &str, credential: &str, trust: &str, access: &str| {
format!(
"{{ repository_access = {{ {repo} = {{ ssh = {{ credential = {credential:?}; host_trust = {trust:?}; access = {access:?}; }}; }}; }}; }}"
)
};
for canonical in [false, true] {
let current = f.source().await;
assert_eq!(current.source_stored, canonical);
let before = f.durable();
for (case, repo, credential, trust, access, diagnostic) in [
(
"mode",
"remote",
"deploy",
"matching",
"not-a-mode",
"invalid Repository access config",
),
(
"repository",
"absent",
"deploy",
"matching",
"read_only",
"unknown Repository",
),
(
"non-ssh",
"nonssh",
"deploy",
"matching",
"read_only",
"not an ssh",
),
(
"credential",
"remote",
"absent",
"matching",
"read_only",
"unknown Repository SSH credential",
),
(
"inactive",
"remote",
"revoked",
"matching",
"read_only",
"is not active",
),
(
"trust",
"remote",
"deploy",
"absent",
"read_only",
"unknown Repository SSH host trust",
),
(
"host",
"remote",
"deploy",
"wrong-host",
"read_only",
"does not match host trust",
),
(
"port",
"remote",
"deploy",
"wrong-port",
"read_only",
"does not match host trust",
),
] {
let adoption = request(
&current,
&format!("repository-{canonical}-{case}"),
vec![update(
&current,
"main.dcdl",
&content(repo, credential, trust, access),
)],
);
for endpoint in ["/preview", "/commit"] {
let rejected = f
.owner_call(endpoint, &adoption, StatusCode::BAD_REQUEST)
.await;
assert!(
rejected.to_string().contains(diagnostic),
"{case} {endpoint}: {rejected}"
);
assert_eq!(
f.durable(),
before,
"{case} {endpoint}: all old authorities must remain unchanged"
);
assert_eq!(
secret_counts(),
secrets_before,
"validation must not generate keys or secret receipts"
);
if canonical {
f.assert_legacy_sealed();
} else {
f.assert_legacy_writable();
}
}
}
let valid = request(
&current,
&format!("valid-repository-{canonical}"),
vec![update(
&current,
"main.dcdl",
&content(
"remote",
"deploy",
"matching",
if canonical { "read_write" } else { "read_only" },
),
)],
);
let preview = f.owner_call("/preview", &valid, StatusCode::OK).await;
assert_eq!(
f.durable(),
before,
"valid preview must remain a read, before and after cutover"
);
assert_eq!(secret_counts(), secrets_before);
let saved = f.commit(&valid).await;
assert_eq!(
serde_json::to_value(&saved.state).unwrap(),
preview["state"]
);
assert_eq!(f.source().await.state, saved.state);
f.assert_legacy_sealed();
assert_eq!(
secret_counts(),
secrets_before,
"adoption validates metadata without secret side effects"
);
}
assert!(
f.api
.repository_secrets
.get_credential(TEST_WORKSPACE_ID, "workspace-default", &[])
.unwrap()
.is_none()
);
}
#[tokio::test] #[tokio::test]
async fn owner_http_edits_migration_draft_and_adopts_source_policy_head_and_receipt() { async fn owner_http_edits_migration_draft_and_adopts_source_policy_head_and_receipt() {
let fixture = Fixture::new().await; let fixture = Fixture::new().await;
+19 -13
View File
@@ -6586,19 +6586,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
workspace_id: &str, workspace_id: &str,
repository_key: &str, repository_key: &str,
) -> Result<Option<RepositoryRecord>> { ) -> Result<Option<RepositoryRecord>> {
self.with_conn(|conn| { self.with_conn(|conn| read_repository_by_key(conn, workspace_id, repository_key))
conn.query_row(
r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref,
source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories
WHERE workspace_id = ?1 AND repository_key = ?2"#,
params![workspace_id, repository_key],
read_repository_record,
)
.optional()
.map_err(Error::from)
})
} }
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>> { fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>> {
@@ -12225,6 +12213,24 @@ fn validate_repository_record_identity(record: &RepositoryRecord) -> Result<()>
Ok(()) Ok(())
} }
pub(crate) fn read_repository_by_key(
conn: &Connection,
workspace_id: &str,
repository_key: &str,
) -> Result<Option<RepositoryRecord>> {
conn.query_row(
r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref,
source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories
WHERE workspace_id = ?1 AND repository_key = ?2"#,
params![workspace_id, repository_key],
read_repository_record,
)
.optional()
.map_err(Error::from)
}
fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> { fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> {
let source_kind_value = row.get::<_, String>(5)?; let source_kind_value = row.get::<_, String>(5)?;
let source_kind = server_api::RepositorySourceKind::parse(&source_kind_value) let source_kind = server_api::RepositorySourceKind::parse(&source_kind_value)