fix: validate repository access within owner adoption transaction
This commit is contained in:
@@ -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(
|
||||||
|
¤t,
|
||||||
|
&format!("repository-{canonical}-{case}"),
|
||||||
|
vec![update(
|
||||||
|
¤t,
|
||||||
|
"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(
|
||||||
|
¤t,
|
||||||
|
&format!("valid-repository-{canonical}"),
|
||||||
|
vec![update(
|
||||||
|
¤t,
|
||||||
|
"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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user