diff --git a/apps/mcp-server/tests/integration/catalog_access.rs b/apps/mcp-server/tests/integration/catalog_access.rs index 6c65487..cc1e284 100644 --- a/apps/mcp-server/tests/integration/catalog_access.rs +++ b/apps/mcp-server/tests/integration/catalog_access.rs @@ -17,14 +17,17 @@ use axum::{ }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ - Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod, - Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, - PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId, + Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest, + ApprovalRequestId, ApprovalRequestStatus, ExecutionConfig, HttpMethod, Operation, + OperationApprovalRiskLevel, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, + PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, + WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::{ - CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry, - PublishAgentRequest, PublishRequest, SaveAgentBindingsRequest, + CreateAgentRequest, CreateApprovalRequest, CreatePlatformApiKeyRequest, + ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest, + SaveAgentBindingsRequest, }; use crank_runtime::{ InMemoryCoordinationStateStore, RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor, @@ -508,6 +511,120 @@ async fn rejects_initialize_with_approval_platform_api_key() { assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn approval_key_lists_and_decides_pending_requests() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_operation(&upstream_base_url, "crm_human_approval"); + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + publish_agent_with_bindings( + ®istry, + "sales-human-approval", + vec![binding_for_operation(&operation)], + ) + .await; + let approval = ApprovalRequest { + id: ApprovalRequestId::new("approval_mcp_01"), + workspace_id: test_workspace_id(), + agent_id: test_agent_id("sales-human-approval"), + operation_id: operation.id.clone(), + operation_version: 1, + status: ApprovalRequestStatus::Pending, + risk_level: OperationApprovalRiskLevel::Dangerous, + confirmation_title: "Подтвердите создание лида".to_owned(), + confirmation_body: "Проверьте email перед отправкой в CRM.".to_owned(), + request_payload: json!({"email": "ada@example.com"}), + response_payload: None, + created_at: OffsetDateTime::now_utc(), + expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5), + decided_at: None, + decided_by_key_id: None, + decision_note: None, + }; + registry + .create_approval_request(CreateApprovalRequest { + approval: &approval, + }) + .await + .unwrap(); + + let approval_key = + create_approval_platform_api_key(®istry, "sales-human-approval", "approval-http").await; + let mcp_key = create_platform_api_key( + ®istry, + "sales-human-approval", + "mcp-human-approval", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let approvals_url = format!( + "{}/approvals", + agent_mcp_url(&base_url, "sales-human-approval") + ); + + let rejected = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {mcp_key}")) + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), reqwest::StatusCode::UNAUTHORIZED); + + let pending = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .send() + .await + .unwrap(); + assert_eq!(pending.status(), reqwest::StatusCode::OK); + let pending_body = pending.json::().await.unwrap(); + assert_eq!(pending_body["items"].as_array().unwrap().len(), 1); + assert_eq!( + pending_body["items"][0]["approval"]["confirmation_title"], + "Подтвердите создание лида" + ); + + let approve_url = format!( + "{}/approvals/{}/approve", + agent_mcp_url(&base_url, "sales-human-approval"), + approval.id + ); + let approved = client + .post(&approve_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .json(&json!({ "approve": "yes", "note": "confirmed by test" })) + .send() + .await + .unwrap(); + assert_eq!(approved.status(), reqwest::StatusCode::OK); + let approved_body = approved.json::().await.unwrap(); + assert_eq!( + approved_body["approval"]["status"], + Value::String("approved".to_owned()) + ); + + let pending_after = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert!(pending_after["items"].as_array().unwrap().is_empty()); +} + #[tokio::test] async fn rejects_tool_call_with_read_only_platform_api_key() { let registry = test_registry().await; diff --git a/apps/mcp-server/tests/integration/common.rs b/apps/mcp-server/tests/integration/common.rs index 420fb56..c44de4c 100644 --- a/apps/mcp-server/tests/integration/common.rs +++ b/apps/mcp-server/tests/integration/common.rs @@ -258,7 +258,11 @@ pub(super) async fn create_approval_platform_api_key( agent_slug, name, PlatformApiKeyKind::Approval, - &[PlatformApiKeyScope::Approve, PlatformApiKeyScope::Deny], + &[ + PlatformApiKeyScope::ReadPending, + PlatformApiKeyScope::Approve, + PlatformApiKeyScope::Deny, + ], "crk_appr", ) .await diff --git a/apps/ui/js/api-keys.js b/apps/ui/js/api-keys.js index d767cad..5ac0b41 100644 --- a/apps/ui/js/api-keys.js +++ b/apps/ui/js/api-keys.js @@ -8,7 +8,7 @@ var search = ''; var SCOPES_BY_KIND = { mcp_client: ['read', 'write', 'deploy'], - approval: ['approve', 'deny'], + approval: ['read_pending', 'approve', 'deny'], }; var SCOPE_DESC = { read: 'apikeys.scope.read', @@ -16,6 +16,7 @@ var SCOPE_DESC = { deploy: 'apikeys.scope.deploy', approve: 'apikeys.scope.approve', deny: 'apikeys.scope.deny', + read_pending: 'apikeys.scope.read_pending', }; function tKey(key) { diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index 4265662..c1b3920 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -126,6 +126,7 @@ var TRANSLATIONS = { 'apikeys.scope.deploy': 'Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.', 'apikeys.scope.approve': 'Confirm a pending human approval request for this agent.', 'apikeys.scope.deny': 'Reject a pending human approval request for this agent.', + 'apikeys.scope.read_pending': 'Read pending human approval requests for this agent.', 'apikeys.modal.title': 'Create agent key', 'apikeys.modal.title_mcp': 'Create MCP client key', 'apikeys.modal.title_approval': 'Create approval key', @@ -967,8 +968,9 @@ var TRANSLATIONS = { 'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для выбранного AI-агента.', 'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.', 'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.', - 'apikeys.scope.approve': 'Подтверждение ожидающего human approval запроса для этого агента.', - 'apikeys.scope.deny': 'Отклонение ожидающего human approval запроса для этого агента.', + 'apikeys.scope.approve': 'Подтверждение ожидающего запроса для этого агента.', + 'apikeys.scope.deny': 'Отклонение ожидающего запроса для этого агента.', + 'apikeys.scope.read_pending': 'Получение списка запросов, ожидающих подтверждения для этого агента.', 'apikeys.modal.title': 'Создать ключ агента', 'apikeys.modal.title_mcp': 'Создать ключ MCP-клиента', 'apikeys.modal.title_approval': 'Создать ключ подтверждения', diff --git a/crates/crank-community-mcp/src/access.rs b/crates/crank-community-mcp/src/access.rs index 0f74a79..9315f50 100644 --- a/crates/crank-community-mcp/src/access.rs +++ b/crates/crank-community-mcp/src/access.rs @@ -27,6 +27,41 @@ pub(super) async fn require_machine_access( Ok(credential) } +pub(super) async fn require_approval_access( + state: &Arc, + path: &AgentRoutePath, + headers: &HeaderMap, + required_scope: PlatformApiKeyScope, +) -> Result { + let secret = bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?; + let secret_hash = hash_access_secret(secret); + let Some(api_key) = state + .registry + .get_approval_api_key_by_secret_for_agent_slug( + &path.workspace_slug, + &path.agent_slug, + &secret_hash, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + else { + return Err(StatusCode::UNAUTHORIZED); + }; + + if !approval_allows_scope(&api_key.api_key.scopes, required_scope) { + return Err(StatusCode::FORBIDDEN); + } + + let used_at = OffsetDateTime::now_utc(); + state + .registry + .touch_platform_api_key(&api_key.api_key.workspace_id, &api_key.api_key.id, &used_at) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(api_key) +} + pub(super) fn bearer_token(headers: &HeaderMap) -> Option<&str> { let value = headers.get(AUTHORIZATION)?.to_str().ok()?; let (scheme, token) = value.split_once(' ')?; @@ -136,6 +171,20 @@ fn allows_scope(scopes: &[PlatformApiKeyScope], required_scope: PlatformApiKeySc } } +fn approval_allows_scope( + scopes: &[PlatformApiKeyScope], + required_scope: PlatformApiKeyScope, +) -> bool { + scopes.iter().any(|scope| match required_scope { + PlatformApiKeyScope::Approve => matches!(scope, PlatformApiKeyScope::Approve), + PlatformApiKeyScope::Deny => matches!(scope, PlatformApiKeyScope::Deny), + PlatformApiKeyScope::ReadPending => matches!(scope, PlatformApiKeyScope::ReadPending), + PlatformApiKeyScope::Read | PlatformApiKeyScope::Write | PlatformApiKeyScope::Deploy => { + false + } + }) +} + fn security_level_rank(level: OperationSecurityLevel) -> u8 { match level { OperationSecurityLevel::Standard => 0, diff --git a/crates/crank-community-mcp/src/app.rs b/crates/crank-community-mcp/src/app.rs index 434f698..b69c948 100644 --- a/crates/crank-community-mcp/src/app.rs +++ b/crates/crank-community-mcp/src/app.rs @@ -10,13 +10,16 @@ use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response, sse::Event}, - routing::get, + routing::{get, post}, }; use crank_core::{ - AuthProfile, CoordinationStateStore, InvocationLevel, InvocationLog, InvocationLogId, - InvocationSource, InvocationStatus, PlatformApiKeyScope, SecretId, + ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore, InvocationLevel, + InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, PlatformApiKeyScope, + SecretId, +}; +use crank_registry::{ + CreateInvocationLogRequest, DecideApprovalRequest, PostgresRegistry, PublishedAgentTool, }; -use crank_registry::{CreateInvocationLogRequest, PostgresRegistry, PublishedAgentTool}; use crank_runtime::{ RequestRateLimiter, ResolvedAuth, RuntimeError, RuntimeExecutionRequest, RuntimeExecutor, RuntimeOperation, RuntimeRequestContext, SecretCrypto, @@ -29,8 +32,8 @@ use tracing::info; use crate::{ access::{ - credential_allows_security_level, require_machine_access, serialize_machine_access_mode, - serialize_security_level, + credential_allows_security_level, require_approval_access, require_machine_access, + serialize_machine_access_mode, serialize_security_level, }, auth::{SharedMachineCredentialVerifier, VerifiedMachineCredential}, catalog::PublishedToolCatalog, @@ -83,6 +86,13 @@ struct ToolCallParams { arguments: Value, } +#[derive(Debug, Deserialize)] +struct ApprovalDecisionPayload { + approve: String, + #[serde(default)] + note: Option, +} + #[derive(Clone)] struct ResolvedToolCall { tool: PublishedAgentTool, @@ -100,6 +110,13 @@ pub(super) struct AgentRoutePath { pub(super) agent_slug: String, } +#[derive(Clone, Debug, Deserialize)] +struct ApprovalRoutePath { + workspace_slug: String, + agent_slug: String, + approval_id: String, +} + #[allow(clippy::too_many_arguments)] pub fn build_app( registry: PostgresRegistry, @@ -129,6 +146,18 @@ pub fn build_app( "/v1/{workspace_slug}/{agent_slug}", get(mcp_get).post(mcp_post).delete(mcp_delete), ) + .route( + "/v1/{workspace_slug}/{agent_slug}/approvals", + get(list_pending_approvals), + ) + .route( + "/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/approve", + post(approve_request), + ) + .route( + "/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/deny", + post(deny_request), + ) .with_state(state) } @@ -139,6 +168,122 @@ async fn health() -> Json { })) } +async fn list_pending_approvals( + Path(path): Path, + State(state): State>, + headers: HeaderMap, +) -> Response { + let key = + match require_approval_access(&state, &path, &headers, PlatformApiKeyScope::ReadPending) + .await + { + Ok(key) => key, + Err(status) => return status.into_response(), + }; + + let Some(agent_id) = key.api_key.agent_id.as_ref() else { + return StatusCode::FORBIDDEN.into_response(); + }; + + match state + .registry + .list_pending_approval_requests_for_agent(&key.api_key.workspace_id, agent_id) + .await + { + Ok(items) => Json(json!({ "items": items })).into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +async fn approve_request( + Path(path): Path, + State(state): State>, + headers: HeaderMap, + Json(payload): Json, +) -> Response { + decide_approval_request( + path, + state, + headers, + payload, + PlatformApiKeyScope::Approve, + ApprovalRequestStatus::Approved, + ) + .await +} + +async fn deny_request( + Path(path): Path, + State(state): State>, + headers: HeaderMap, + Json(payload): Json, +) -> Response { + decide_approval_request( + path, + state, + headers, + payload, + PlatformApiKeyScope::Deny, + ApprovalRequestStatus::Denied, + ) + .await +} + +async fn decide_approval_request( + path: ApprovalRoutePath, + state: Arc, + headers: HeaderMap, + payload: ApprovalDecisionPayload, + required_scope: PlatformApiKeyScope, + status: ApprovalRequestStatus, +) -> Response { + let agent_path = AgentRoutePath { + workspace_slug: path.workspace_slug, + agent_slug: path.agent_slug, + }; + let key = match require_approval_access(&state, &agent_path, &headers, required_scope).await { + Ok(key) => key, + Err(status) => return status.into_response(), + }; + + if (status == ApprovalRequestStatus::Approved && !payload.approve.eq_ignore_ascii_case("yes")) + || (status == ApprovalRequestStatus::Denied && !payload.approve.eq_ignore_ascii_case("no")) + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "invalid_decision_payload", + "message": "approve must be yes for approve endpoint and no for deny endpoint" + })), + ) + .into_response(); + } + + let Some(agent_id) = key.api_key.agent_id.as_ref() else { + return StatusCode::FORBIDDEN.into_response(); + }; + let approval_id = ApprovalRequestId::new(path.approval_id); + + match state + .registry + .decide_approval_request(DecideApprovalRequest { + workspace_id: &key.api_key.workspace_id, + agent_id, + approval_id: &approval_id, + status, + decided_at: OffsetDateTime::now_utc(), + decided_by_key_id: &key.api_key.id, + response_payload: Some(json!({ "approve": payload.approve })), + decision_note: payload.note.as_deref(), + }) + .await + { + Ok(Some(record)) => Json(json!(record)).into_response(), + Ok(None) => StatusCode::CONFLICT.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + async fn mcp_get( Path(path): Path, State(state): State>, diff --git a/crates/crank-core/src/approval.rs b/crates/crank-core/src/approval.rs new file mode 100644 index 0000000..7bc7701 --- /dev/null +++ b/crates/crank-core/src/approval.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; + +use crate::ids::{AgentId, ApprovalRequestId, OperationId, PlatformApiKeyId, WorkspaceId}; +use crate::operation::OperationApprovalRiskLevel; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalRequestStatus { + #[default] + Pending, + Approved, + Denied, + Expired, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ApprovalRequest { + pub id: ApprovalRequestId, + pub workspace_id: WorkspaceId, + pub agent_id: AgentId, + pub operation_id: OperationId, + pub operation_version: u32, + pub status: ApprovalRequestStatus, + pub risk_level: OperationApprovalRiskLevel, + pub confirmation_title: String, + pub confirmation_body: String, + pub request_payload: Value, + pub response_payload: Option, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339::option")] + pub decided_at: Option, + pub decided_by_key_id: Option, + pub decision_note: Option, +} diff --git a/crates/crank-core/src/ids.rs b/crates/crank-core/src/ids.rs index 29c3617..b259bdb 100644 --- a/crates/crank-core/src/ids.rs +++ b/crates/crank-core/src/ids.rs @@ -53,6 +53,7 @@ define_id!(UserSessionId); define_id!(AgentId); define_id!(InvitationId); define_id!(PlatformApiKeyId); +define_id!(ApprovalRequestId); define_id!(InvocationLogId); define_id!(AuditEventId); define_id!(SecretId); diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index e896341..48d2d31 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod access; pub mod agent; +pub mod approval; pub mod auth; pub mod cache; pub mod edition; @@ -18,6 +19,7 @@ pub mod domain { PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus, }; pub use crate::agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion}; + pub use crate::approval::{ApprovalRequest, ApprovalRequestStatus}; pub use crate::auth::{ ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig, BearerAuthConfig, @@ -31,9 +33,9 @@ pub mod domain { ProductEdition, }; pub use crate::ids::{ - AgentId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, - OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId, - WorkspaceId, + AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, + InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, + UserSessionId, WorkspaceId, }; pub use crate::observability::{ InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, @@ -89,6 +91,7 @@ pub use access::{ PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus, }; pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion}; +pub use approval::{ApprovalRequest, ApprovalRequestStatus}; pub use auth::{ ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig, BearerAuthConfig, @@ -121,8 +124,9 @@ pub use ext::protocol::{ SharedProtocolAdapter, }; pub use ids::{ - AgentId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId, - PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId, WorkspaceId, + AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, + InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, + UserSessionId, WorkspaceId, }; pub use observability::{ InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup, diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index 9be46e2..8b7727b 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -9,12 +9,12 @@ pub use ext::{ExtensionMigration, RegistryExtension, apply_extension_migrations} pub mod records { pub use crate::model::{ - AgentSummary, AgentVersionRecord, AuthUserRecord, DescriptorKind, DescriptorMetadata, - ImportJob, ImportJobId, ImportJobKind, ImportJobStatus, InvitationRecord, - InvocationLogRecord, MembershipRecord, OperationAgentRef, OperationSampleMetadata, - OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, - PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind, SecretRecord, - SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket, + AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord, DescriptorKind, + DescriptorMetadata, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus, + InvitationRecord, InvocationLogRecord, MembershipRecord, OperationAgentRef, + OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, + Page, PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind, + SecretRecord, SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, @@ -23,13 +23,14 @@ pub mod records { pub mod requests { pub use crate::model::{ - CreateAgentDraftVersionRequest, CreateAgentRequest, CreateImportJobRequest, - CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, - CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, - CreateYamlImportJobRequest, FinishImportJobRequest, ListInvocationLogsQuery, - PublishAgentRequest, PublishRequest, RotateSecretRequest, SaveAgentBindingsRequest, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageQuery, + CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest, + CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest, + CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, + CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, + FinishImportJobRequest, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest, + RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest, + SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, + UpdateWorkspaceRequest, UsageQuery, }; } @@ -39,15 +40,16 @@ pub mod infrastructure { } pub use model::{ - AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentDraftVersionRequest, - CreateAgentRequest, CreateImportJobRequest, CreateInvitationRequest, - CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, - CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, - DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, ImportJobKind, - ImportJobStatus, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, - MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, - OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest, - PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind, + AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord, + CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest, + CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest, + CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, + CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata, + FinishImportJobRequest, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus, + InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, + OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary, + OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, + PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, diff --git a/crates/crank-registry/src/migrations.rs b/crates/crank-registry/src/migrations.rs index c0ef191..a9c7a9d 100644 --- a/crates/crank-registry/src/migrations.rs +++ b/crates/crank-registry/src/migrations.rs @@ -559,6 +559,35 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + query( + "create table if not exists approval_requests ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + agent_id text not null references agents(id) on delete cascade, + operation_id text not null references operations(id) on delete cascade, + operation_version integer not null, + status text not null, + risk_level text not null, + confirmation_title text not null, + confirmation_body text not null, + request_payload_json jsonb not null, + response_payload_json jsonb null, + created_at timestamptz not null, + expires_at timestamptz not null, + decided_at timestamptz null, + decided_by_key_id text null references platform_api_keys(id) on delete set null, + decision_note text 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)", + ) + .execute(pool) + .await?; + query( "create table if not exists invocation_logs ( id text primary key, diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index d53454b..4b92375 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -1,9 +1,10 @@ use crank_core::{ - Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId, - ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, - Operation, OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, Protocol, - SampleId, Secret, SecretId, SecretVersion, UsagePeriod, UsageRollup, User, UserSessionId, - Workspace, WorkspaceId, + Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest, + ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExportMode, + InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, Operation, + OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, + Protocol, SampleId, Secret, SecretId, SecretVersion, UsagePeriod, UsageRollup, User, + UserSessionId, Workspace, WorkspaceId, }; use crank_mapping::MappingSet; use crank_schema::Schema; @@ -95,6 +96,11 @@ pub struct PlatformApiKeyRecord { pub api_key: PlatformApiKey, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ApprovalRequestRecord { + pub approval: ApprovalRequest, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SecretRecord { pub secret: Secret, @@ -513,6 +519,23 @@ pub struct CreatePlatformApiKeyRequest<'a> { pub secret_hash: &'a str, } +#[derive(Clone, Debug)] +pub struct CreateApprovalRequest<'a> { + pub approval: &'a ApprovalRequest, +} + +#[derive(Clone, Debug)] +pub struct DecideApprovalRequest<'a> { + pub workspace_id: &'a WorkspaceId, + pub agent_id: &'a AgentId, + pub approval_id: &'a ApprovalRequestId, + pub status: ApprovalRequestStatus, + pub decided_at: OffsetDateTime, + pub decided_by_key_id: &'a PlatformApiKeyId, + pub response_payload: Option, + pub decision_note: Option<&'a str>, +} + #[derive(Clone, Debug, PartialEq)] pub struct CreateSecretRequest<'a> { pub secret: &'a Secret, diff --git a/crates/crank-registry/src/postgres/api_key.rs b/crates/crank-registry/src/postgres/api_key.rs index c30439c..ebe9782 100644 --- a/crates/crank-registry/src/postgres/api_key.rs +++ b/crates/crank-registry/src/postgres/api_key.rs @@ -170,6 +170,66 @@ impl PostgresRegistry { .transpose() } + pub async fn get_approval_api_key_by_secret_for_agent_slug( + &self, + workspace_slug: &str, + agent_slug: &str, + secret_hash: &str, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + k.id, + k.workspace_id, + k.agent_id, + k.key_kind, + k.name, + k.prefix, + k.scopes_json, + k.status, + k.created_at, + k.last_used_at, + k.expires_at, + k.allowed_origins_json + from platform_api_keys k + join workspaces w on w.id = k.workspace_id + join agents a on a.id = k.agent_id + where w.slug = $1 + and a.slug = $2 + and k.secret_hash = $3 + and k.key_kind = 'approval' + and k.status = 'active' + and (k.expires_at is null or k.expires_at > now()) + limit 1", + ) + .bind(workspace_slug) + .bind(agent_slug) + .bind(secret_hash) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + Ok(PlatformApiKeyRecord { + api_key: PlatformApiKey { + id: PlatformApiKeyId::new(row.get::("id")), + workspace_id: WorkspaceId::new(row.get::("workspace_id")), + agent_id: row.get::, _>("agent_id").map(AgentId::new), + key_kind: deserialize_enum_text(&row.get::("key_kind"), "key_kind")?, + name: row.get("name"), + prefix: row.get("prefix"), + scopes: deserialize_json_value(row.get::("scopes_json"))?, + status: deserialize_enum_text(&row.get::("status"), "status")?, + created_at: row.get("created_at"), + last_used_at: row.get("last_used_at"), + expires_at: row.get("expires_at"), + allowed_origins: deserialize_json_value( + row.get::("allowed_origins_json"), + )?, + }, + }) + }) + .transpose() + } + pub async fn create_platform_api_key( &self, request: CreatePlatformApiKeyRequest<'_>, diff --git a/crates/crank-registry/src/postgres/approval.rs b/crates/crank-registry/src/postgres/approval.rs new file mode 100644 index 0000000..3113ba1 --- /dev/null +++ b/crates/crank-registry/src/postgres/approval.rs @@ -0,0 +1,218 @@ +use super::*; + +impl PostgresRegistry { + pub async fn create_approval_request( + &self, + request: CreateApprovalRequest<'_>, + ) -> Result<(), RegistryError> { + sqlx::query( + "insert into approval_requests ( + id, + workspace_id, + agent_id, + operation_id, + operation_version, + status, + risk_level, + confirmation_title, + confirmation_body, + request_payload_json, + response_payload_json, + created_at, + expires_at, + decided_at, + decided_by_key_id, + decision_note + ) values ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12::timestamptz, $13::timestamptz, $14::timestamptz, + $15, $16 + )", + ) + .bind(request.approval.id.as_str()) + .bind(request.approval.workspace_id.as_str()) + .bind(request.approval.agent_id.as_str()) + .bind(request.approval.operation_id.as_str()) + .bind(to_db_version(request.approval.operation_version)) + .bind(serialize_enum_text( + &request.approval.status, + "approval_status", + )?) + .bind(serialize_enum_text( + &request.approval.risk_level, + "approval_risk_level", + )?) + .bind(&request.approval.confirmation_title) + .bind(&request.approval.confirmation_body) + .bind(Json(&request.approval.request_payload)) + .bind(request.approval.response_payload.as_ref().map(Json)) + .bind(request.approval.created_at) + .bind(request.approval.expires_at) + .bind(request.approval.decided_at) + .bind( + request + .approval + .decided_by_key_id + .as_ref() + .map(PlatformApiKeyId::as_str), + ) + .bind(request.approval.decision_note.as_deref()) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn list_pending_approval_requests_for_agent( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + id, + workspace_id, + agent_id, + operation_id, + operation_version, + status, + risk_level, + confirmation_title, + confirmation_body, + request_payload_json, + response_payload_json, + created_at, + expires_at, + decided_at, + decided_by_key_id, + decision_note + from approval_requests + where workspace_id = $1 + and agent_id = $2 + and status = 'pending' + and expires_at > now() + order by created_at asc", + ) + .bind(workspace_id.as_str()) + .bind(agent_id.as_str()) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(map_approval_request_row).collect() + } + + pub async fn get_approval_request_for_agent( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + approval_id: &ApprovalRequestId, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + id, + workspace_id, + agent_id, + operation_id, + operation_version, + status, + risk_level, + confirmation_title, + confirmation_body, + request_payload_json, + response_payload_json, + created_at, + expires_at, + decided_at, + decided_by_key_id, + decision_note + from approval_requests + where workspace_id = $1 + and agent_id = $2 + and id = $3 + limit 1", + ) + .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 decide_approval_request( + &self, + request: DecideApprovalRequest<'_>, + ) -> Result, RegistryError> { + let row = sqlx::query( + "update approval_requests + set status = $1, + response_payload_json = $2, + decided_at = $3::timestamptz, + decided_by_key_id = $4, + decision_note = $5 + where workspace_id = $6 + and agent_id = $7 + and id = $8 + and status = 'pending' + and expires_at > $3::timestamptz + returning + id, + workspace_id, + agent_id, + operation_id, + operation_version, + status, + risk_level, + confirmation_title, + confirmation_body, + request_payload_json, + response_payload_json, + created_at, + expires_at, + decided_at, + decided_by_key_id, + decision_note", + ) + .bind(serialize_enum_text(&request.status, "approval_status")?) + .bind(request.response_payload.as_ref().map(Json)) + .bind(request.decided_at) + .bind(request.decided_by_key_id.as_str()) + .bind(request.decision_note) + .bind(request.workspace_id.as_str()) + .bind(request.agent_id.as_str()) + .bind(request.approval_id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.map(map_approval_request_row).transpose() + } +} + +fn map_approval_request_row(row: PgRow) -> Result { + Ok(ApprovalRequestRecord { + approval: ApprovalRequest { + id: ApprovalRequestId::new(row.get::("id")), + workspace_id: WorkspaceId::new(row.get::("workspace_id")), + agent_id: AgentId::new(row.get::("agent_id")), + operation_id: OperationId::new(row.get::("operation_id")), + operation_version: from_db_version(row.get("operation_version"), "operation_version")?, + status: deserialize_enum_text(&row.get::("status"), "approval_status")?, + risk_level: deserialize_enum_text( + &row.get::("risk_level"), + "approval_risk_level", + )?, + confirmation_title: row.get("confirmation_title"), + confirmation_body: row.get("confirmation_body"), + request_payload: row.get::("request_payload_json"), + response_payload: row.get::, _>("response_payload_json"), + created_at: row.get("created_at"), + expires_at: row.get("expires_at"), + decided_at: row.get("decided_at"), + decided_by_key_id: row + .get::, _>("decided_by_key_id") + .map(PlatformApiKeyId::new), + decision_note: row.get("decision_note"), + }, + }) +} diff --git a/crates/crank-registry/src/postgres/mod.rs b/crates/crank-registry/src/postgres/mod.rs index e377fa6..2b6a8f4 100644 --- a/crates/crank-registry/src/postgres/mod.rs +++ b/crates/crank-registry/src/postgres/mod.rs @@ -1,5 +1,6 @@ mod agent; mod api_key; +mod approval; mod auth; mod connection; mod import_job; @@ -13,10 +14,11 @@ mod workspace; mod yaml_import; use crank_core::{ - AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, HttpMethod, - InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole, OperationId, - OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Secret, SecretId, - SecretVersion, Target, UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId, + AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest, ApprovalRequestId, + AuthProfile, HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, + MembershipRole, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, + PlatformApiKeyStatus, Secret, SecretId, SecretVersion, Target, UsageRollup, User, UserId, + UserSessionId, Workspace, WorkspaceId, }; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; @@ -28,10 +30,11 @@ pub use pool_config::{PostgresPoolConfig, PostgresPoolConfigError}; use crate::{ error::RegistryError, model::{ - AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentDraftVersionRequest, - CreateAgentRequest, CreateImportJobRequest, CreateInvitationRequest, - CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, - CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, + AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord, + CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest, + CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest, + CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, + CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, diff --git a/crates/crank-registry/tests/integration/workspace_access.rs b/crates/crank-registry/tests/integration/workspace_access.rs index 8167f7c..69f7fea 100644 --- a/crates/crank-registry/tests/integration/workspace_access.rs +++ b/crates/crank-registry/tests/integration/workspace_access.rs @@ -5,13 +5,13 @@ use super::common::*; use std::collections::BTreeMap; use crank_core::{ - AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig, AuthConfig, - AuthKind, AuthProfile, ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft, - GeneratedDraftStatus, HttpMethod, InvocationLog, MembershipRole, OperationId, - OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, - PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, - SecretId, Target, ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState, - Workspace, WorkspaceId, + AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig, + ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthConfig, AuthKind, AuthProfile, + ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft, GeneratedDraftStatus, HttpMethod, + InvocationLog, MembershipRole, OperationApprovalRiskLevel, OperationId, OperationSecurityLevel, + OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope, + PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretId, Target, + ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState, Workspace, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_schema::{Schema, SchemaKind}; @@ -20,13 +20,13 @@ use sqlx::{Executor, PgPool, postgres::PgPoolOptions}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crank_registry::{ - CreateAgentRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, - CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, - DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry, - PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, - YamlImportJobStatus, + CreateAgentRequest, CreateApprovalRequest, CreateInvocationLogRequest, + CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, + CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata, + OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, + PublishRequest, RegistryError, RegistryOperation, SampleKind, SaveAuthProfileRequest, + SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest, + WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, }; fn test_workspace_id() -> WorkspaceId { @@ -455,3 +455,126 @@ async fn manages_platform_api_key_read_paths() { database.cleanup().await; } + +#[tokio::test] +async fn manages_approval_request_lifecycle() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let workspace_id = test_workspace_id(); + let operation = test_operation("op_approval_01", 1, OperationStatus::Draft); + let agent = test_agent("agent_approval_01", AgentStatus::Draft); + let version = test_agent_version(&agent.id, 1, AgentStatus::Draft); + let approval_key = PlatformApiKey { + id: PlatformApiKeyId::new("key_approval_01"), + workspace_id: workspace_id.clone(), + agent_id: Some(agent.id.clone()), + key_kind: PlatformApiKeyKind::Approval, + name: "Approval key".to_owned(), + prefix: "crk_appr".to_owned(), + scopes: vec![PlatformApiKeyScope::Approve, PlatformApiKeyScope::Deny], + status: PlatformApiKeyStatus::Active, + created_at: timestamp("2026-03-25T12:00:00Z"), + last_used_at: None, + expires_at: None, + allowed_origins: Vec::new(), + }; + let approval = ApprovalRequest { + id: ApprovalRequestId::new("approval_01"), + workspace_id: workspace_id.clone(), + agent_id: agent.id.clone(), + operation_id: operation.id.clone(), + operation_version: 1, + status: ApprovalRequestStatus::Pending, + risk_level: OperationApprovalRiskLevel::Dangerous, + confirmation_title: "Confirm action".to_owned(), + confirmation_body: "Check payload before running.".to_owned(), + request_payload: json!({"amount": 100}), + response_payload: None, + created_at: timestamp("2026-03-25T12:01:00Z"), + expires_at: timestamp("2027-03-25T12:06:00Z"), + decided_at: None, + decided_by_key_id: None, + decision_note: None, + }; + + registry + .create_operation(&workspace_id, &operation, None) + .await + .unwrap(); + registry + .create_agent(CreateAgentRequest { + agent: &agent, + version: &version, + bindings: &[], + }) + .await + .unwrap(); + registry + .create_platform_api_key(CreatePlatformApiKeyRequest { + api_key: &approval_key, + secret_hash: "approval-secret-hash", + }) + .await + .unwrap(); + registry + .create_approval_request(CreateApprovalRequest { + approval: &approval, + }) + .await + .unwrap(); + + let pending = registry + .list_pending_approval_requests_for_agent(&workspace_id, &agent.id) + .await + .unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].approval, approval); + + let decided = registry + .decide_approval_request(DecideApprovalRequest { + workspace_id: &workspace_id, + agent_id: &agent.id, + approval_id: &approval.id, + status: ApprovalRequestStatus::Approved, + decided_at: timestamp("2026-03-25T12:02:00Z"), + decided_by_key_id: &approval_key.id, + response_payload: Some(json!({"approve": "yes"})), + decision_note: Some("confirmed in test"), + }) + .await + .unwrap() + .unwrap(); + + assert_eq!(decided.approval.status, ApprovalRequestStatus::Approved); + assert_eq!( + decided.approval.decided_by_key_id, + Some(approval_key.id.clone()) + ); + assert_eq!( + decided.approval.response_payload, + Some(json!({"approve": "yes"})) + ); + + let pending_after_decision = registry + .list_pending_approval_requests_for_agent(&workspace_id, &agent.id) + .await + .unwrap(); + assert!(pending_after_decision.is_empty()); + + let repeated_decision = registry + .decide_approval_request(DecideApprovalRequest { + workspace_id: &workspace_id, + agent_id: &agent.id, + approval_id: &approval.id, + status: ApprovalRequestStatus::Denied, + decided_at: timestamp("2026-03-25T12:03:00Z"), + decided_by_key_id: &approval_key.id, + response_payload: Some(json!({"approve": "no"})), + decision_note: None, + }) + .await + .unwrap(); + assert!(repeated_decision.is_none()); + + database.cleanup().await; +}