feat(resource/broker): 实现受保护凭证代办与项目 readiness #20

Merged
lanrtop merged 5 commits from feat/project-resource-broker into master 2026-09-23 20:36:21 +08:00
6 changed files with 175 additions and 12 deletions
Showing only changes of commit 47ff656083 - Show all commits

View File

@ -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 用户下的进程隔离不是本方案提供的安全保证。

View File

@ -15,7 +15,7 @@ contracts:
- "src-tauri/src/commands/toolchain.rs"
- "src-tauri/src/commands/cicd.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: 被管理项目身份上下文
paths:

View File

@ -131,17 +131,23 @@ pub fn delete_api_key_fn(
store: &impl crate::credential_store::CredentialStore,
id: &str,
) -> 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(
"SELECT credential_ref FROM api_keys WHERE id=?1",
params![id],
|row| row.get(0),
)
.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())?;
if let Some(reference) = reference {
let _ = store.delete(&reference);
let references: Vec<String> = reference.into_iter().collect();
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(())
}
@ -301,4 +307,23 @@ mod tests {
delete_api_key_fn(&conn, &store, &key.id).unwrap();
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())
);
}
}

View File

@ -110,7 +110,7 @@ fn credential_readiness(
if has_legacy {
match store.get("resource-readiness/probe") {
Ok(_) => ResourceReadiness {
state: "configured",
state: "pending_migration",
source: "legacy_pending_migration",
},
Err(_) => ResourceReadiness {
@ -277,10 +277,19 @@ fn delete_gitea_instance_with_store(
.map_err(|_| "删除 Gitea 实例失败".to_string())?
.collect::<rusqlite::Result<_>>()
.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())?;
for reference in instance_reference.into_iter().chain(repo_references) {
let _ = store.delete(&reference);
tx.execute("DELETE FROM gitea_instances WHERE id = ?1", params![id])
.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(())
}
@ -2237,6 +2246,72 @@ mod tests {
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]
fn gitea_auth_migrates_legacy_token_and_update_preserves_old_on_store_failure() {
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_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();
assert_eq!(legacy.gitea_pr.state, "configured");
assert_eq!(legacy.gitea_pr.state, "pending_migration");
assert_eq!(legacy.gitea_pr.source, "legacy_pending_migration");
let unavailable = project_resource_status_for_conn(
&conn,

View File

@ -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 {
fn columns(self) -> (&'static str, &'static str, &'static str, &'static str, bool) {
match self {
@ -362,6 +398,8 @@ pub(crate) struct MemoryCredentialStore {
values: Mutex<HashMap<String, Vec<u8>>>,
fail_writes: bool,
fail_reads: bool,
fail_deletes: bool,
fail_delete_reference: Option<String>,
}
#[cfg(test)]
@ -371,6 +409,8 @@ impl MemoryCredentialStore {
values: Mutex::new(HashMap::new()),
fail_writes: false,
fail_reads: false,
fail_deletes: false,
fail_delete_reference: None,
}
}
pub(crate) fn failing() -> Self {
@ -378,6 +418,26 @@ impl MemoryCredentialStore {
values: Mutex::new(HashMap::new()),
fail_writes: 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 {
@ -412,6 +472,9 @@ impl CredentialStore for MemoryCredentialStore {
.cloned())
}
fn delete(&self, reference: &str) -> Result<(), String> {
if self.fail_deletes || self.fail_delete_reference.as_deref() == Some(reference) {
return Err("模拟凭证存储删除失败".into());
}
self.values
.lock()
.map_err(|_| "凭证存储锁不可用".to_string())?

View File

@ -1803,7 +1803,7 @@ export const giteaTestConnection = (instanceId: string) =>
invoke<GiteaUser>("gitea_test_connection", { instanceId });
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";
}