feat(resource/broker): 实现受保护凭证代办与项目 readiness #20
@ -141,7 +141,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
|
|||||||
|
|
||||||
## 项目资源与凭证代办边界
|
## 项目资源与凭证代办边界
|
||||||
|
|
||||||
`get_project_resource_status(project_id)` 是同一项目绑定上的只读 MCP/Tauri 投影,只报告 Gitea PR 与 webhook 的 `configured`、`not_configured`、`unavailable` 或 `needs_repair` 及非秘密来源。scoped MCP 连接拒绝跨项目参数;状态不含原始凭证、凭证引用、仓库 URL 或连接串。`configured` 仅表示本机代办凭证就绪,不代表远端 Gitea、CI 或部署已验证。
|
`get_project_resource_status(project_id)` 是同一项目绑定上的只读 MCP/Tauri 投影,只报告 Gitea PR 与 webhook 的 `configured`、`pending_migration`、`not_configured`、`unavailable` 或 `needs_repair` 及非秘密来源。旧 SQLite 凭证未迁移时只报告 `pending_migration`,不提前宣称可执行;scoped MCP 连接拒绝跨项目参数;状态不含原始凭证、凭证引用、仓库 URL 或连接串。`configured` 仅表示本机代办凭证就绪,不代表远端 Gitea、CI 或部署已验证。
|
||||||
|
|
||||||
Gitea PR 继续使用既有 `create_pull_request` 有界操作;Windows 本机代办凭证由 Credential Manager 保护,SQLite 只存引用及非秘密元数据。旧值在显式操作时,先在数据库同目录以 SQLite `VACUUM INTO` 生成 `dev-manager.pre-credential-migration.db`,校验完整性和当前旧值确在备份中,再按写入、读取校验、事务切换引用并清除旧列的顺序迁移;备份不可用或失败则保留旧值并报告 `needs_repair`。此备份可能含历史明文,须按原数据库同等敏感级别保护,不能因当前列清空就宣称历史明文已消失。普通 Gitea/API Key DTO 不带原始值;人的 API Key 查看与复制仍通过本机 Tauri 的显式 reveal,MCP 不暴露该入口。Gitea Actions secrets、项目运行时密钥与服务器凭证仍留在各自原用途位置;炼境不提供 `get_secret` 或任意 SSH、SQL、HTTP、部署代理。同一 Windows 用户下的进程隔离不是本方案提供的安全保证。
|
Gitea PR 继续使用既有 `create_pull_request` 有界操作;Windows 本机代办凭证由 Credential Manager 保护,SQLite 只存引用及非秘密元数据。旧值在显式操作时,先在数据库同目录以 SQLite `VACUUM INTO` 生成 `dev-manager.pre-credential-migration.db`,校验完整性和当前旧值确在备份中,再按写入、读取校验、事务切换引用并清除旧列的顺序迁移;备份不可用或失败则保留旧值并报告 `needs_repair`。此备份可能含历史明文,须按原数据库同等敏感级别保护,不能因当前列清空就宣称历史明文已消失。普通 Gitea/API Key DTO 不带原始值;人的 API Key 查看与复制仍通过本机 Tauri 的显式 reveal,MCP 不暴露该入口。Gitea Actions secrets、项目运行时密钥与服务器凭证仍留在各自原用途位置;炼境不提供 `get_secret` 或任意 SSH、SQL、HTTP、部署代理。同一 Windows 用户下的进程隔离不是本方案提供的安全保证。
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ contracts:
|
|||||||
- "src-tauri/src/commands/toolchain.rs"
|
- "src-tauri/src/commands/toolchain.rs"
|
||||||
- "src-tauri/src/commands/cicd.rs"
|
- "src-tauri/src/commands/cicd.rs"
|
||||||
- "src-tauri/src/commands/gitea.rs"
|
- "src-tauri/src/commands/gitea.rs"
|
||||||
doc: "工具 schema、dispatch 与调用说明必须同步;get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
|
doc: "工具 schema、dispatch 与调用说明必须同步;get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
|
||||||
|
|
||||||
- label: 被管理项目身份上下文
|
- label: 被管理项目身份上下文
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@ -131,17 +131,23 @@ pub fn delete_api_key_fn(
|
|||||||
store: &impl crate::credential_store::CredentialStore,
|
store: &impl crate::credential_store::CredentialStore,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let reference: Option<String> = conn
|
let tx = conn
|
||||||
|
.unchecked_transaction()
|
||||||
|
.map_err(|_| "删除 API Key 失败".to_string())?;
|
||||||
|
let reference: Option<String> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT credential_ref FROM api_keys WHERE id=?1",
|
"SELECT credential_ref FROM api_keys WHERE id=?1",
|
||||||
params![id],
|
params![id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.map_err(|_| "API Key 不存在".to_string())?;
|
.map_err(|_| "API Key 不存在".to_string())?;
|
||||||
conn.execute("DELETE FROM api_keys WHERE id=?1", params![id])
|
tx.execute("DELETE FROM api_keys WHERE id=?1", params![id])
|
||||||
.map_err(|_| "删除 API Key 失败".to_string())?;
|
.map_err(|_| "删除 API Key 失败".to_string())?;
|
||||||
if let Some(reference) = reference {
|
let references: Vec<String> = reference.into_iter().collect();
|
||||||
let _ = store.delete(&reference);
|
let snapshots = crate::credential_store::delete_references(store, &references)?;
|
||||||
|
if tx.commit().is_err() {
|
||||||
|
crate::credential_store::restore_references(store, &snapshots)?;
|
||||||
|
return Err("删除 API Key 失败:数据库提交失败".into());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -301,4 +307,23 @@ mod tests {
|
|||||||
delete_api_key_fn(&conn, &store, &key.id).unwrap();
|
delete_api_key_fn(&conn, &store, &key.id).unwrap();
|
||||||
assert!(store.get(&reference).unwrap().is_none());
|
assert!(store.get(&reference).unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_failure_keeps_api_key_and_credential_reference() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
let store = crate::credential_store::MemoryCredentialStore::failing_deletes();
|
||||||
|
store.put("api-key/k1/v1", b"synthetic-key").unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO api_keys (id, name, provider, api_key, credential_ref) VALUES ('k1', 'key', 'test', '', 'api-key/k1/v1')",
|
||||||
|
[],
|
||||||
|
).unwrap();
|
||||||
|
assert!(delete_api_key_fn(&conn, &store, "k1")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("needs_repair"));
|
||||||
|
assert!(get_api_key(&conn, "k1").is_some());
|
||||||
|
assert_eq!(
|
||||||
|
store.get("api-key/k1/v1").unwrap().as_deref(),
|
||||||
|
Some(b"synthetic-key".as_slice())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -110,7 +110,7 @@ fn credential_readiness(
|
|||||||
if has_legacy {
|
if has_legacy {
|
||||||
match store.get("resource-readiness/probe") {
|
match store.get("resource-readiness/probe") {
|
||||||
Ok(_) => ResourceReadiness {
|
Ok(_) => ResourceReadiness {
|
||||||
state: "configured",
|
state: "pending_migration",
|
||||||
source: "legacy_pending_migration",
|
source: "legacy_pending_migration",
|
||||||
},
|
},
|
||||||
Err(_) => ResourceReadiness {
|
Err(_) => ResourceReadiness {
|
||||||
@ -277,10 +277,19 @@ fn delete_gitea_instance_with_store(
|
|||||||
.map_err(|_| "删除 Gitea 实例失败".to_string())?
|
.map_err(|_| "删除 Gitea 实例失败".to_string())?
|
||||||
.collect::<rusqlite::Result<_>>()
|
.collect::<rusqlite::Result<_>>()
|
||||||
.map_err(|_| "删除 Gitea 实例失败".to_string())?;
|
.map_err(|_| "删除 Gitea 实例失败".to_string())?;
|
||||||
conn.execute("DELETE FROM gitea_instances WHERE id = ?1", params![id])
|
let tx = conn
|
||||||
|
.unchecked_transaction()
|
||||||
.map_err(|_| "删除 Gitea 实例失败".to_string())?;
|
.map_err(|_| "删除 Gitea 实例失败".to_string())?;
|
||||||
for reference in instance_reference.into_iter().chain(repo_references) {
|
tx.execute("DELETE FROM gitea_instances WHERE id = ?1", params![id])
|
||||||
let _ = store.delete(&reference);
|
.map_err(|_| "删除 Gitea 实例失败".to_string())?;
|
||||||
|
let references: Vec<String> = instance_reference
|
||||||
|
.into_iter()
|
||||||
|
.chain(repo_references)
|
||||||
|
.collect();
|
||||||
|
let snapshots = crate::credential_store::delete_references(store, &references)?;
|
||||||
|
if tx.commit().is_err() {
|
||||||
|
crate::credential_store::restore_references(store, &snapshots)?;
|
||||||
|
return Err("删除 Gitea 实例失败:数据库提交失败".into());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -2237,6 +2246,72 @@ mod tests {
|
|||||||
assert_eq!(default_count, 1, "失败的保存不应改变已有默认实例");
|
assert_eq!(default_count, 1, "失败的保存不应改变已有默认实例");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_instance_keeps_instance_and_repo_when_protected_store_delete_fails() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
let store = crate::credential_store::MemoryCredentialStore::failing_deletes();
|
||||||
|
store
|
||||||
|
.put("gitea/access-token/i/v1", b"synthetic-token")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO gitea_instances (id, name, url, access_token, access_token_ref) VALUES ('i', 'test', 'https://example.invalid', '', 'gitea/access-token/i/v1')",
|
||||||
|
[],
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES ('r', 'i', 'p', 'owner', 'repo')",
|
||||||
|
[],
|
||||||
|
).unwrap();
|
||||||
|
assert!(delete_gitea_instance_with_store(&conn, &store, "i")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("needs_repair"));
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM gitea_repos WHERE id='r'", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
assert_eq!(
|
||||||
|
store.get("gitea/access-token/i/v1").unwrap().as_deref(),
|
||||||
|
Some(b"synthetic-token".as_slice())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_instance_restores_token_when_later_webhook_delete_fails() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
let store = crate::credential_store::MemoryCredentialStore::failing_delete_for(
|
||||||
|
"gitea/webhook-secret/p/v1",
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.put("gitea/access-token/i/v1", b"synthetic-token")
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.put("gitea/webhook-secret/p/v1", b"synthetic-hook")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO gitea_instances (id, name, url, access_token, access_token_ref) VALUES ('i', 'test', 'https://example.invalid', '', 'gitea/access-token/i/v1')", []).unwrap();
|
||||||
|
conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret_ref) VALUES ('r', 'i', 'p', 'owner', 'repo', 'gitea/webhook-secret/p/v1')", []).unwrap();
|
||||||
|
|
||||||
|
assert!(delete_gitea_instance_with_store(&conn, &store, "i")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("needs_repair"));
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM gitea_instances WHERE id='i'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
assert_eq!(
|
||||||
|
store.get("gitea/access-token/i/v1").unwrap().as_deref(),
|
||||||
|
Some(b"synthetic-token".as_slice())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.get("gitea/webhook-secret/p/v1").unwrap().as_deref(),
|
||||||
|
Some(b"synthetic-hook".as_slice())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gitea_auth_migrates_legacy_token_and_update_preserves_old_on_store_failure() {
|
fn gitea_auth_migrates_legacy_token_and_update_preserves_old_on_store_failure() {
|
||||||
let conn = crate::db::conn_with_schema();
|
let conn = crate::db::conn_with_schema();
|
||||||
@ -2531,7 +2606,7 @@ mod tests {
|
|||||||
conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i', 'test', 'https://secret-host.invalid', 'synthetic-token')", []).unwrap();
|
conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i', 'test', 'https://secret-host.invalid', 'synthetic-token')", []).unwrap();
|
||||||
conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret) VALUES ('r', 'i', 'p1', 'sensitive-owner', 'sensitive-repo', 'synthetic-webhook-secret')", []).unwrap();
|
conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret) VALUES ('r', 'i', 'p1', 'sensitive-owner', 'sensitive-repo', 'synthetic-webhook-secret')", []).unwrap();
|
||||||
let legacy = project_resource_status_for_conn(&conn, &store, "p1").unwrap();
|
let legacy = project_resource_status_for_conn(&conn, &store, "p1").unwrap();
|
||||||
assert_eq!(legacy.gitea_pr.state, "configured");
|
assert_eq!(legacy.gitea_pr.state, "pending_migration");
|
||||||
assert_eq!(legacy.gitea_pr.source, "legacy_pending_migration");
|
assert_eq!(legacy.gitea_pr.source, "legacy_pending_migration");
|
||||||
let unavailable = project_resource_status_for_conn(
|
let unavailable = project_resource_status_for_conn(
|
||||||
&conn,
|
&conn,
|
||||||
|
|||||||
@ -95,6 +95,42 @@ pub fn put_verified(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 在数据库删除事务提交前移除凭证;若中途失败,用快照恢复已删除的引用。
|
||||||
|
pub fn delete_references(
|
||||||
|
store: &impl CredentialStore,
|
||||||
|
references: &[String],
|
||||||
|
) -> Result<Vec<(String, Option<Vec<u8>>)>, String> {
|
||||||
|
let mut snapshots = Vec::new();
|
||||||
|
for reference in references {
|
||||||
|
if !snapshots.iter().any(|(saved, _)| saved == reference) {
|
||||||
|
let value = store
|
||||||
|
.get(reference)
|
||||||
|
.map_err(|_| "凭证状态 needs_repair:删除前无法读取受保护存储".to_string())?;
|
||||||
|
snapshots.push((reference.clone(), value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index in 0..snapshots.len() {
|
||||||
|
if store.delete(&snapshots[index].0).is_err() {
|
||||||
|
restore_references(store, &snapshots[..=index])?;
|
||||||
|
return Err("凭证状态 needs_repair:受保护存储删除失败,数据库记录已保留".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(snapshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_references(
|
||||||
|
store: &impl CredentialStore,
|
||||||
|
snapshots: &[(String, Option<Vec<u8>>)],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for (reference, value) in snapshots {
|
||||||
|
if let Some(value) = value {
|
||||||
|
put_verified(store, reference, value)
|
||||||
|
.map_err(|_| "凭证状态 needs_repair:受保护存储凭证恢复失败".to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
impl LegacyCredentialKind {
|
impl LegacyCredentialKind {
|
||||||
fn columns(self) -> (&'static str, &'static str, &'static str, &'static str, bool) {
|
fn columns(self) -> (&'static str, &'static str, &'static str, &'static str, bool) {
|
||||||
match self {
|
match self {
|
||||||
@ -362,6 +398,8 @@ pub(crate) struct MemoryCredentialStore {
|
|||||||
values: Mutex<HashMap<String, Vec<u8>>>,
|
values: Mutex<HashMap<String, Vec<u8>>>,
|
||||||
fail_writes: bool,
|
fail_writes: bool,
|
||||||
fail_reads: bool,
|
fail_reads: bool,
|
||||||
|
fail_deletes: bool,
|
||||||
|
fail_delete_reference: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -371,6 +409,8 @@ impl MemoryCredentialStore {
|
|||||||
values: Mutex::new(HashMap::new()),
|
values: Mutex::new(HashMap::new()),
|
||||||
fail_writes: false,
|
fail_writes: false,
|
||||||
fail_reads: false,
|
fail_reads: false,
|
||||||
|
fail_deletes: false,
|
||||||
|
fail_delete_reference: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub(crate) fn failing() -> Self {
|
pub(crate) fn failing() -> Self {
|
||||||
@ -378,6 +418,26 @@ impl MemoryCredentialStore {
|
|||||||
values: Mutex::new(HashMap::new()),
|
values: Mutex::new(HashMap::new()),
|
||||||
fail_writes: true,
|
fail_writes: true,
|
||||||
fail_reads: true,
|
fail_reads: true,
|
||||||
|
fail_deletes: true,
|
||||||
|
fail_delete_reference: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn failing_deletes() -> Self {
|
||||||
|
Self {
|
||||||
|
values: Mutex::new(HashMap::new()),
|
||||||
|
fail_writes: false,
|
||||||
|
fail_reads: false,
|
||||||
|
fail_deletes: true,
|
||||||
|
fail_delete_reference: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn failing_delete_for(reference: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
values: Mutex::new(HashMap::new()),
|
||||||
|
fail_writes: false,
|
||||||
|
fail_reads: false,
|
||||||
|
fail_deletes: false,
|
||||||
|
fail_delete_reference: Some(reference.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub(crate) fn contains_value(&self, value: &[u8]) -> bool {
|
pub(crate) fn contains_value(&self, value: &[u8]) -> bool {
|
||||||
@ -412,6 +472,9 @@ impl CredentialStore for MemoryCredentialStore {
|
|||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
fn delete(&self, reference: &str) -> Result<(), String> {
|
fn delete(&self, reference: &str) -> Result<(), String> {
|
||||||
|
if self.fail_deletes || self.fail_delete_reference.as_deref() == Some(reference) {
|
||||||
|
return Err("模拟凭证存储删除失败".into());
|
||||||
|
}
|
||||||
self.values
|
self.values
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "凭证存储锁不可用".to_string())?
|
.map_err(|_| "凭证存储锁不可用".to_string())?
|
||||||
|
|||||||
@ -1803,7 +1803,7 @@ export const giteaTestConnection = (instanceId: string) =>
|
|||||||
invoke<GiteaUser>("gitea_test_connection", { instanceId });
|
invoke<GiteaUser>("gitea_test_connection", { instanceId });
|
||||||
|
|
||||||
export interface ResourceReadiness {
|
export interface ResourceReadiness {
|
||||||
state: "configured" | "not_configured" | "unavailable" | "needs_repair";
|
state: "configured" | "pending_migration" | "not_configured" | "unavailable" | "needs_repair";
|
||||||
source: "protected_store" | "legacy_pending_migration" | "gitea_binding";
|
source: "protected_store" | "legacy_pending_migration" | "gitea_binding";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user