Усилить безопасность и надёжность выполнения операций
This commit is contained in:
@@ -11,6 +11,7 @@ crank-mapping = { path = "../crank-mapping" }
|
||||
crank-schema = { path = "../crank-schema" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
|
||||
@@ -586,6 +586,22 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
query("alter table approval_requests drop column if exists confirmation_body")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
query("alter table approval_requests add column if not exists execution_started_at timestamptz null")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
query("alter table approval_requests add column if not exists execution_attempts integer not null default 0")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
query("alter table approval_requests add column if not exists request_fingerprint text null")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists approval_requests_pending_fingerprint_idx
|
||||
on approval_requests(agent_id, operation_id, operation_version, request_fingerprint)
|
||||
where status = 'pending' and request_fingerprint is not null",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
query(
|
||||
"create index if not exists approval_requests_agent_status_idx
|
||||
on approval_requests(workspace_id, agent_id, status, expires_at)",
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
use super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn create_approval_request(
|
||||
&self,
|
||||
request: CreateApprovalRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
) -> Result<ApprovalRequestRecord, RegistryError> {
|
||||
let fingerprint = approval_request_fingerprint(&request.approval.request_payload)?;
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
sqlx::query(
|
||||
"update approval_requests
|
||||
set status = 'expired'
|
||||
where agent_id = $1
|
||||
and operation_id = $2
|
||||
and operation_version = $3
|
||||
and request_fingerprint = $4
|
||||
and status = 'pending'
|
||||
and expires_at <= $5",
|
||||
)
|
||||
.bind(request.approval.agent_id.as_str())
|
||||
.bind(request.approval.operation_id.as_str())
|
||||
.bind(to_db_version(request.approval.operation_version))
|
||||
.bind(&fingerprint)
|
||||
.bind(request.approval.created_at)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
let row = sqlx::query(
|
||||
"insert into approval_requests (
|
||||
id,
|
||||
workspace_id,
|
||||
@@ -20,12 +40,20 @@ impl PostgresRegistry {
|
||||
expires_at,
|
||||
decided_at,
|
||||
decided_by_key_id,
|
||||
decision_note
|
||||
decision_note,
|
||||
request_fingerprint
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||
$9, $10::timestamptz, $11::timestamptz, $12::timestamptz,
|
||||
$13, $14
|
||||
)",
|
||||
$13, $14, $15
|
||||
)
|
||||
on conflict (agent_id, operation_id, operation_version, request_fingerprint)
|
||||
where status = 'pending' and request_fingerprint is not null
|
||||
do update set request_fingerprint = excluded.request_fingerprint
|
||||
returning
|
||||
id, workspace_id, agent_id, operation_id, operation_version,
|
||||
status, risk_level, request_payload_json, response_payload_json,
|
||||
created_at, expires_at, decided_at, decided_by_key_id, decision_note",
|
||||
)
|
||||
.bind(request.approval.id.as_str())
|
||||
.bind(request.approval.workspace_id.as_str())
|
||||
@@ -53,10 +81,12 @@ impl PostgresRegistry {
|
||||
.map(PlatformApiKeyId::as_str),
|
||||
)
|
||||
.bind(request.approval.decision_note.as_deref())
|
||||
.execute(&self.pool)
|
||||
.bind(fingerprint)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
map_approval_request_row(row)
|
||||
}
|
||||
|
||||
pub async fn list_pending_approval_requests_for_agent(
|
||||
@@ -263,7 +293,7 @@ impl PostgresRegistry {
|
||||
where workspace_id = $4
|
||||
and agent_id = $5
|
||||
and id = $6
|
||||
and status = 'approved'
|
||||
and status = 'executing'
|
||||
returning
|
||||
id,
|
||||
workspace_id,
|
||||
@@ -292,6 +322,75 @@ impl PostgresRegistry {
|
||||
row.map(map_approval_request_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn claim_approval_request(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
approval_id: &ApprovalRequestId,
|
||||
started_at: OffsetDateTime,
|
||||
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"update approval_requests
|
||||
set status = 'executing',
|
||||
execution_started_at = $1,
|
||||
execution_attempts = execution_attempts + 1
|
||||
where workspace_id = $2
|
||||
and agent_id = $3
|
||||
and id = $4
|
||||
and status = 'approved'
|
||||
returning
|
||||
id, workspace_id, agent_id, operation_id, operation_version,
|
||||
status, risk_level, request_payload_json, response_payload_json,
|
||||
created_at, expires_at, decided_at, decided_by_key_id, decision_note",
|
||||
)
|
||||
.bind(started_at)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.bind(approval_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(map_approval_request_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn claim_next_recoverable_approval_request(
|
||||
&self,
|
||||
started_at: OffsetDateTime,
|
||||
approved_before: OffsetDateTime,
|
||||
stale_before: OffsetDateTime,
|
||||
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"with candidate as (
|
||||
select id
|
||||
from approval_requests
|
||||
where (status = 'approved' and decided_at <= $1)
|
||||
or (status = 'executing' and execution_started_at < $2)
|
||||
order by decided_at asc nulls last, created_at asc
|
||||
for update skip locked
|
||||
limit 1
|
||||
)
|
||||
update approval_requests as approval
|
||||
set status = 'executing',
|
||||
execution_started_at = $3,
|
||||
execution_attempts = approval.execution_attempts + 1
|
||||
from candidate
|
||||
where approval.id = candidate.id
|
||||
returning
|
||||
approval.id, approval.workspace_id, approval.agent_id,
|
||||
approval.operation_id, approval.operation_version, approval.status,
|
||||
approval.risk_level, approval.request_payload_json,
|
||||
approval.response_payload_json, approval.created_at, approval.expires_at,
|
||||
approval.decided_at, approval.decided_by_key_id, approval.decision_note",
|
||||
)
|
||||
.bind(approved_before)
|
||||
.bind(stale_before)
|
||||
.bind(started_at)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(map_approval_request_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn expire_approval_request(
|
||||
&self,
|
||||
request: ExpireApprovalRequest<'_>,
|
||||
@@ -331,6 +430,26 @@ impl PostgresRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_request_fingerprint(payload: &Value) -> Result<String, RegistryError> {
|
||||
let canonical = canonical_json(payload);
|
||||
let encoded = serde_json::to_vec(&canonical)?;
|
||||
Ok(format!("{:x}", Sha256::digest(encoded)))
|
||||
}
|
||||
|
||||
fn canonical_json(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let sorted = object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), canonical_json(value)))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
Value::Object(sorted.into_iter().collect())
|
||||
}
|
||||
Value::Array(items) => Value::Array(items.iter().map(canonical_json).collect()),
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_approval_request_row(row: PgRow) -> Result<ApprovalRequestRecord, RegistryError> {
|
||||
Ok(ApprovalRequestRecord {
|
||||
approval: ApprovalRequest {
|
||||
|
||||
@@ -5,6 +5,9 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: CreateInvocationLogRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let request_preview = crank_core::sanitize_invocation_preview(&request.log.request_preview);
|
||||
let response_preview =
|
||||
crank_core::sanitize_invocation_preview(&request.log.response_preview);
|
||||
sqlx::query(
|
||||
"insert into invocation_logs (
|
||||
id,
|
||||
@@ -45,8 +48,8 @@ impl PostgresRegistry {
|
||||
}
|
||||
})?)
|
||||
.bind(&request.log.error_kind)
|
||||
.bind(Json(request.log.request_preview.clone()))
|
||||
.bind(Json(request.log.response_preview.clone()))
|
||||
.bind(Json(request_preview))
|
||||
.bind(Json(response_preview))
|
||||
.bind(request.log.created_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
@@ -515,12 +515,23 @@ async fn manages_approval_request_lifecycle() {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
let created = registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.approval.id, approval.id);
|
||||
let mut duplicate = approval.clone();
|
||||
duplicate.id = ApprovalRequestId::new("approval_02");
|
||||
duplicate.request_payload = json!({"amount": 100});
|
||||
let deduplicated = registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &duplicate,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(deduplicated.approval.id, approval.id);
|
||||
|
||||
let pending = registry
|
||||
.list_pending_approval_requests_for_agent(&workspace_id, &agent.id)
|
||||
@@ -572,6 +583,48 @@ async fn manages_approval_request_lifecycle() {
|
||||
Some(json!({"approve": "yes"}))
|
||||
);
|
||||
|
||||
let approval_inside_recovery_grace = registry
|
||||
.claim_next_recoverable_approval_request(
|
||||
timestamp("2026-03-25T12:02:01Z"),
|
||||
timestamp("2026-03-25T12:01:59Z"),
|
||||
timestamp("2026-03-25T11:55:00Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(approval_inside_recovery_grace.is_none());
|
||||
|
||||
let claimed = registry
|
||||
.claim_approval_request(
|
||||
&workspace_id,
|
||||
&agent.id,
|
||||
&approval.id,
|
||||
timestamp("2026-03-25T12:02:01Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(claimed.approval.status, ApprovalRequestStatus::Executing);
|
||||
let fresh_claim = registry
|
||||
.claim_next_recoverable_approval_request(
|
||||
timestamp("2026-03-25T12:02:10Z"),
|
||||
timestamp("2026-03-25T12:02:09Z"),
|
||||
timestamp("2026-03-25T12:01:59Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(fresh_claim.is_none());
|
||||
let recovered = registry
|
||||
.claim_next_recoverable_approval_request(
|
||||
timestamp("2026-03-25T12:03:00Z"),
|
||||
timestamp("2026-03-25T12:02:59Z"),
|
||||
timestamp("2026-03-25T12:02:30Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(recovered.approval.id, approval.id);
|
||||
assert_eq!(recovered.approval.status, ApprovalRequestStatus::Executing);
|
||||
|
||||
let completed = registry
|
||||
.finish_approval_request(FinishApprovalRequest {
|
||||
workspace_id: &workspace_id,
|
||||
@@ -626,5 +679,26 @@ async fn manages_approval_request_lifecycle() {
|
||||
.unwrap();
|
||||
assert!(repeated_decision.is_none());
|
||||
|
||||
let mut expired = approval.clone();
|
||||
expired.id = ApprovalRequestId::new("approval_expired_pending");
|
||||
expired.request_payload = json!({"amount": 200});
|
||||
expired.created_at = timestamp("2026-03-25T12:04:00Z");
|
||||
expired.expires_at = timestamp("2026-03-25T12:04:01Z");
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest { approval: &expired })
|
||||
.await
|
||||
.unwrap();
|
||||
let mut replacement = expired.clone();
|
||||
replacement.id = ApprovalRequestId::new("approval_replacement_pending");
|
||||
replacement.created_at = timestamp("2026-03-25T12:05:00Z");
|
||||
replacement.expires_at = timestamp("2026-03-25T12:10:00Z");
|
||||
let replaced = registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &replacement,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replaced.approval.id, replacement.id);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user