Add approval request HTTP surface
This commit is contained in:
@@ -17,14 +17,17 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
|
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest,
|
||||||
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
|
ApprovalRequestId, ApprovalRequestStatus, ExecutionConfig, HttpMethod, Operation,
|
||||||
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
|
OperationApprovalRiskLevel, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
||||||
|
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
|
||||||
|
WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_mapping::{MappingRule, MappingSet};
|
use crank_mapping::{MappingRule, MappingSet};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry,
|
CreateAgentRequest, CreateApprovalRequest, CreatePlatformApiKeyRequest,
|
||||||
PublishAgentRequest, PublishRequest, SaveAgentBindingsRequest,
|
ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest,
|
||||||
|
SaveAgentBindingsRequest,
|
||||||
};
|
};
|
||||||
use crank_runtime::{
|
use crank_runtime::{
|
||||||
InMemoryCoordinationStateStore, RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor,
|
InMemoryCoordinationStateStore, RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor,
|
||||||
@@ -508,6 +511,120 @@ async fn rejects_initialize_with_approval_platform_api_key() {
|
|||||||
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
|
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::<Value>().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::<Value>().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::<Value>()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(pending_after["items"].as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rejects_tool_call_with_read_only_platform_api_key() {
|
async fn rejects_tool_call_with_read_only_platform_api_key() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
|
|||||||
@@ -258,7 +258,11 @@ pub(super) async fn create_approval_platform_api_key(
|
|||||||
agent_slug,
|
agent_slug,
|
||||||
name,
|
name,
|
||||||
PlatformApiKeyKind::Approval,
|
PlatformApiKeyKind::Approval,
|
||||||
&[PlatformApiKeyScope::Approve, PlatformApiKeyScope::Deny],
|
&[
|
||||||
|
PlatformApiKeyScope::ReadPending,
|
||||||
|
PlatformApiKeyScope::Approve,
|
||||||
|
PlatformApiKeyScope::Deny,
|
||||||
|
],
|
||||||
"crk_appr",
|
"crk_appr",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ var search = '';
|
|||||||
|
|
||||||
var SCOPES_BY_KIND = {
|
var SCOPES_BY_KIND = {
|
||||||
mcp_client: ['read', 'write', 'deploy'],
|
mcp_client: ['read', 'write', 'deploy'],
|
||||||
approval: ['approve', 'deny'],
|
approval: ['read_pending', 'approve', 'deny'],
|
||||||
};
|
};
|
||||||
var SCOPE_DESC = {
|
var SCOPE_DESC = {
|
||||||
read: 'apikeys.scope.read',
|
read: 'apikeys.scope.read',
|
||||||
@@ -16,6 +16,7 @@ var SCOPE_DESC = {
|
|||||||
deploy: 'apikeys.scope.deploy',
|
deploy: 'apikeys.scope.deploy',
|
||||||
approve: 'apikeys.scope.approve',
|
approve: 'apikeys.scope.approve',
|
||||||
deny: 'apikeys.scope.deny',
|
deny: 'apikeys.scope.deny',
|
||||||
|
read_pending: 'apikeys.scope.read_pending',
|
||||||
};
|
};
|
||||||
|
|
||||||
function tKey(key) {
|
function tKey(key) {
|
||||||
|
|||||||
+4
-2
@@ -126,6 +126,7 @@ var TRANSLATIONS = {
|
|||||||
'apikeys.scope.deploy': 'Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.',
|
'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.approve': 'Confirm a pending human approval request for this agent.',
|
||||||
'apikeys.scope.deny': 'Reject 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': 'Create agent key',
|
||||||
'apikeys.modal.title_mcp': 'Create MCP client key',
|
'apikeys.modal.title_mcp': 'Create MCP client key',
|
||||||
'apikeys.modal.title_approval': 'Create approval key',
|
'apikeys.modal.title_approval': 'Create approval key',
|
||||||
@@ -967,8 +968,9 @@ var TRANSLATIONS = {
|
|||||||
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для выбранного AI-агента.',
|
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для выбранного AI-агента.',
|
||||||
'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.',
|
'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.',
|
||||||
'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.',
|
'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.',
|
||||||
'apikeys.scope.approve': 'Подтверждение ожидающего human approval запроса для этого агента.',
|
'apikeys.scope.approve': 'Подтверждение ожидающего запроса для этого агента.',
|
||||||
'apikeys.scope.deny': 'Отклонение ожидающего human approval запроса для этого агента.',
|
'apikeys.scope.deny': 'Отклонение ожидающего запроса для этого агента.',
|
||||||
|
'apikeys.scope.read_pending': 'Получение списка запросов, ожидающих подтверждения для этого агента.',
|
||||||
'apikeys.modal.title': 'Создать ключ агента',
|
'apikeys.modal.title': 'Создать ключ агента',
|
||||||
'apikeys.modal.title_mcp': 'Создать ключ MCP-клиента',
|
'apikeys.modal.title_mcp': 'Создать ключ MCP-клиента',
|
||||||
'apikeys.modal.title_approval': 'Создать ключ подтверждения',
|
'apikeys.modal.title_approval': 'Создать ключ подтверждения',
|
||||||
|
|||||||
@@ -27,6 +27,41 @@ pub(super) async fn require_machine_access(
|
|||||||
Ok(credential)
|
Ok(credential)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) async fn require_approval_access(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
path: &AgentRoutePath,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
required_scope: PlatformApiKeyScope,
|
||||||
|
) -> Result<crank_registry::PlatformApiKeyRecord, StatusCode> {
|
||||||
|
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> {
|
pub(super) fn bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||||
let value = headers.get(AUTHORIZATION)?.to_str().ok()?;
|
let value = headers.get(AUTHORIZATION)?.to_str().ok()?;
|
||||||
let (scheme, token) = value.split_once(' ')?;
|
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 {
|
fn security_level_rank(level: OperationSecurityLevel) -> u8 {
|
||||||
match level {
|
match level {
|
||||||
OperationSecurityLevel::Standard => 0,
|
OperationSecurityLevel::Standard => 0,
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::{IntoResponse, Response, sse::Event},
|
response::{IntoResponse, Response, sse::Event},
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
AuthProfile, CoordinationStateStore, InvocationLevel, InvocationLog, InvocationLogId,
|
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore, InvocationLevel,
|
||||||
InvocationSource, InvocationStatus, PlatformApiKeyScope, SecretId,
|
InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, PlatformApiKeyScope,
|
||||||
|
SecretId,
|
||||||
|
};
|
||||||
|
use crank_registry::{
|
||||||
|
CreateInvocationLogRequest, DecideApprovalRequest, PostgresRegistry, PublishedAgentTool,
|
||||||
};
|
};
|
||||||
use crank_registry::{CreateInvocationLogRequest, PostgresRegistry, PublishedAgentTool};
|
|
||||||
use crank_runtime::{
|
use crank_runtime::{
|
||||||
RequestRateLimiter, ResolvedAuth, RuntimeError, RuntimeExecutionRequest, RuntimeExecutor,
|
RequestRateLimiter, ResolvedAuth, RuntimeError, RuntimeExecutionRequest, RuntimeExecutor,
|
||||||
RuntimeOperation, RuntimeRequestContext, SecretCrypto,
|
RuntimeOperation, RuntimeRequestContext, SecretCrypto,
|
||||||
@@ -29,8 +32,8 @@ use tracing::info;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
access::{
|
access::{
|
||||||
credential_allows_security_level, require_machine_access, serialize_machine_access_mode,
|
credential_allows_security_level, require_approval_access, require_machine_access,
|
||||||
serialize_security_level,
|
serialize_machine_access_mode, serialize_security_level,
|
||||||
},
|
},
|
||||||
auth::{SharedMachineCredentialVerifier, VerifiedMachineCredential},
|
auth::{SharedMachineCredentialVerifier, VerifiedMachineCredential},
|
||||||
catalog::PublishedToolCatalog,
|
catalog::PublishedToolCatalog,
|
||||||
@@ -83,6 +86,13 @@ struct ToolCallParams {
|
|||||||
arguments: Value,
|
arguments: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ApprovalDecisionPayload {
|
||||||
|
approve: String,
|
||||||
|
#[serde(default)]
|
||||||
|
note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ResolvedToolCall {
|
struct ResolvedToolCall {
|
||||||
tool: PublishedAgentTool,
|
tool: PublishedAgentTool,
|
||||||
@@ -100,6 +110,13 @@ pub(super) struct AgentRoutePath {
|
|||||||
pub(super) agent_slug: String,
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_app(
|
pub fn build_app(
|
||||||
registry: PostgresRegistry,
|
registry: PostgresRegistry,
|
||||||
@@ -129,6 +146,18 @@ pub fn build_app(
|
|||||||
"/v1/{workspace_slug}/{agent_slug}",
|
"/v1/{workspace_slug}/{agent_slug}",
|
||||||
get(mcp_get).post(mcp_post).delete(mcp_delete),
|
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)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +168,122 @@ async fn health() -> Json<Value> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_pending_approvals(
|
||||||
|
Path(path): Path<AgentRoutePath>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
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<ApprovalRoutePath>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(payload): Json<ApprovalDecisionPayload>,
|
||||||
|
) -> Response {
|
||||||
|
decide_approval_request(
|
||||||
|
path,
|
||||||
|
state,
|
||||||
|
headers,
|
||||||
|
payload,
|
||||||
|
PlatformApiKeyScope::Approve,
|
||||||
|
ApprovalRequestStatus::Approved,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn deny_request(
|
||||||
|
Path(path): Path<ApprovalRoutePath>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(payload): Json<ApprovalDecisionPayload>,
|
||||||
|
) -> Response {
|
||||||
|
decide_approval_request(
|
||||||
|
path,
|
||||||
|
state,
|
||||||
|
headers,
|
||||||
|
payload,
|
||||||
|
PlatformApiKeyScope::Deny,
|
||||||
|
ApprovalRequestStatus::Denied,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decide_approval_request(
|
||||||
|
path: ApprovalRoutePath,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
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(
|
async fn mcp_get(
|
||||||
Path(path): Path<AgentRoutePath>,
|
Path(path): Path<AgentRoutePath>,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
|||||||
@@ -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<Value>,
|
||||||
|
#[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<OffsetDateTime>,
|
||||||
|
pub decided_by_key_id: Option<PlatformApiKeyId>,
|
||||||
|
pub decision_note: Option<String>,
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ define_id!(UserSessionId);
|
|||||||
define_id!(AgentId);
|
define_id!(AgentId);
|
||||||
define_id!(InvitationId);
|
define_id!(InvitationId);
|
||||||
define_id!(PlatformApiKeyId);
|
define_id!(PlatformApiKeyId);
|
||||||
|
define_id!(ApprovalRequestId);
|
||||||
define_id!(InvocationLogId);
|
define_id!(InvocationLogId);
|
||||||
define_id!(AuditEventId);
|
define_id!(AuditEventId);
|
||||||
define_id!(SecretId);
|
define_id!(SecretId);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod access;
|
pub mod access;
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
|
pub mod approval;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod edition;
|
pub mod edition;
|
||||||
@@ -18,6 +19,7 @@ pub mod domain {
|
|||||||
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
||||||
};
|
};
|
||||||
pub use crate::agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
pub use crate::agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
||||||
|
pub use crate::approval::{ApprovalRequest, ApprovalRequestStatus};
|
||||||
pub use crate::auth::{
|
pub use crate::auth::{
|
||||||
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
||||||
BearerAuthConfig,
|
BearerAuthConfig,
|
||||||
@@ -31,9 +33,9 @@ pub mod domain {
|
|||||||
ProductEdition,
|
ProductEdition,
|
||||||
};
|
};
|
||||||
pub use crate::ids::{
|
pub use crate::ids::{
|
||||||
AgentId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId,
|
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
|
||||||
OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId,
|
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
|
||||||
WorkspaceId,
|
UserSessionId, WorkspaceId,
|
||||||
};
|
};
|
||||||
pub use crate::observability::{
|
pub use crate::observability::{
|
||||||
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod,
|
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod,
|
||||||
@@ -89,6 +91,7 @@ pub use access::{
|
|||||||
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
||||||
};
|
};
|
||||||
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
||||||
|
pub use approval::{ApprovalRequest, ApprovalRequestStatus};
|
||||||
pub use auth::{
|
pub use auth::{
|
||||||
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
||||||
BearerAuthConfig,
|
BearerAuthConfig,
|
||||||
@@ -121,8 +124,9 @@ pub use ext::protocol::{
|
|||||||
SharedProtocolAdapter,
|
SharedProtocolAdapter,
|
||||||
};
|
};
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AgentId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId,
|
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
|
||||||
PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId, WorkspaceId,
|
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
|
||||||
|
UserSessionId, WorkspaceId,
|
||||||
};
|
};
|
||||||
pub use observability::{
|
pub use observability::{
|
||||||
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup,
|
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup,
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ pub use ext::{ExtensionMigration, RegistryExtension, apply_extension_migrations}
|
|||||||
|
|
||||||
pub mod records {
|
pub mod records {
|
||||||
pub use crate::model::{
|
pub use crate::model::{
|
||||||
AgentSummary, AgentVersionRecord, AuthUserRecord, DescriptorKind, DescriptorMetadata,
|
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord, DescriptorKind,
|
||||||
ImportJob, ImportJobId, ImportJobKind, ImportJobStatus, InvitationRecord,
|
DescriptorMetadata, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus,
|
||||||
InvocationLogRecord, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
InvitationRecord, InvocationLogRecord, MembershipRecord, OperationAgentRef,
|
||||||
OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
|
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||||
PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind, SecretRecord,
|
Page, PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind,
|
||||||
SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket,
|
SecretRecord, SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket,
|
||||||
UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
|
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
|
||||||
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||||
@@ -23,13 +23,14 @@ pub mod records {
|
|||||||
|
|
||||||
pub mod requests {
|
pub mod requests {
|
||||||
pub use crate::model::{
|
pub use crate::model::{
|
||||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateImportJobRequest,
|
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||||
CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
|
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||||
CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
||||||
CreateYamlImportJobRequest, FinishImportJobRequest, ListInvocationLogsQuery,
|
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||||
PublishAgentRequest, PublishRequest, RotateSecretRequest, SaveAgentBindingsRequest,
|
FinishImportJobRequest, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
|
||||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
||||||
SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageQuery,
|
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
||||||
|
UpdateWorkspaceRequest, UsageQuery,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,15 +40,16 @@ pub mod infrastructure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub use model::{
|
pub use model::{
|
||||||
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentDraftVersionRequest,
|
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord,
|
||||||
CreateAgentRequest, CreateImportJobRequest, CreateInvitationRequest,
|
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
|
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||||
DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, ImportJobKind,
|
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
|
||||||
ImportJobStatus, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
|
FinishImportJobRequest, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus,
|
||||||
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord,
|
||||||
OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest,
|
OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary,
|
||||||
PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
|
OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest,
|
||||||
|
PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
|
||||||
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
||||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
|
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
|
||||||
|
|||||||
@@ -559,6 +559,35 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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(
|
query(
|
||||||
"create table if not exists invocation_logs (
|
"create table if not exists invocation_logs (
|
||||||
id text primary key,
|
id text primary key,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use crank_core::{
|
use crank_core::{
|
||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId,
|
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest,
|
||||||
ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole,
|
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExportMode,
|
||||||
Operation, OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, Protocol,
|
InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, Operation,
|
||||||
SampleId, Secret, SecretId, SecretVersion, UsagePeriod, UsageRollup, User, UserSessionId,
|
OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
||||||
Workspace, WorkspaceId,
|
Protocol, SampleId, Secret, SecretId, SecretVersion, UsagePeriod, UsageRollup, User,
|
||||||
|
UserSessionId, Workspace, WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_mapping::MappingSet;
|
use crank_mapping::MappingSet;
|
||||||
use crank_schema::Schema;
|
use crank_schema::Schema;
|
||||||
@@ -95,6 +96,11 @@ pub struct PlatformApiKeyRecord {
|
|||||||
pub api_key: PlatformApiKey,
|
pub api_key: PlatformApiKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ApprovalRequestRecord {
|
||||||
|
pub approval: ApprovalRequest,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct SecretRecord {
|
pub struct SecretRecord {
|
||||||
pub secret: Secret,
|
pub secret: Secret,
|
||||||
@@ -513,6 +519,23 @@ pub struct CreatePlatformApiKeyRequest<'a> {
|
|||||||
pub secret_hash: &'a str,
|
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<Value>,
|
||||||
|
pub decision_note: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct CreateSecretRequest<'a> {
|
pub struct CreateSecretRequest<'a> {
|
||||||
pub secret: &'a Secret,
|
pub secret: &'a Secret,
|
||||||
|
|||||||
@@ -170,6 +170,66 @@ impl PostgresRegistry {
|
|||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_approval_api_key_by_secret_for_agent_slug(
|
||||||
|
&self,
|
||||||
|
workspace_slug: &str,
|
||||||
|
agent_slug: &str,
|
||||||
|
secret_hash: &str,
|
||||||
|
) -> Result<Option<PlatformApiKeyRecord>, 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::<String, _>("id")),
|
||||||
|
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
|
||||||
|
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
|
||||||
|
key_kind: deserialize_enum_text(&row.get::<String, _>("key_kind"), "key_kind")?,
|
||||||
|
name: row.get("name"),
|
||||||
|
prefix: row.get("prefix"),
|
||||||
|
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
|
||||||
|
status: deserialize_enum_text(&row.get::<String, _>("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::<Value, _>("allowed_origins_json"),
|
||||||
|
)?,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_platform_api_key(
|
pub async fn create_platform_api_key(
|
||||||
&self,
|
&self,
|
||||||
request: CreatePlatformApiKeyRequest<'_>,
|
request: CreatePlatformApiKeyRequest<'_>,
|
||||||
|
|||||||
@@ -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<Vec<ApprovalRequestRecord>, 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<Option<ApprovalRequestRecord>, 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<Option<ApprovalRequestRecord>, 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<ApprovalRequestRecord, RegistryError> {
|
||||||
|
Ok(ApprovalRequestRecord {
|
||||||
|
approval: ApprovalRequest {
|
||||||
|
id: ApprovalRequestId::new(row.get::<String, _>("id")),
|
||||||
|
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
|
||||||
|
agent_id: AgentId::new(row.get::<String, _>("agent_id")),
|
||||||
|
operation_id: OperationId::new(row.get::<String, _>("operation_id")),
|
||||||
|
operation_version: from_db_version(row.get("operation_version"), "operation_version")?,
|
||||||
|
status: deserialize_enum_text(&row.get::<String, _>("status"), "approval_status")?,
|
||||||
|
risk_level: deserialize_enum_text(
|
||||||
|
&row.get::<String, _>("risk_level"),
|
||||||
|
"approval_risk_level",
|
||||||
|
)?,
|
||||||
|
confirmation_title: row.get("confirmation_title"),
|
||||||
|
confirmation_body: row.get("confirmation_body"),
|
||||||
|
request_payload: row.get::<Value, _>("request_payload_json"),
|
||||||
|
response_payload: row.get::<Option<Value>, _>("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::<Option<String>, _>("decided_by_key_id")
|
||||||
|
.map(PlatformApiKeyId::new),
|
||||||
|
decision_note: row.get("decision_note"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod agent;
|
mod agent;
|
||||||
mod api_key;
|
mod api_key;
|
||||||
|
mod approval;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod connection;
|
mod connection;
|
||||||
mod import_job;
|
mod import_job;
|
||||||
@@ -13,10 +14,11 @@ mod workspace;
|
|||||||
mod yaml_import;
|
mod yaml_import;
|
||||||
|
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, HttpMethod,
|
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest, ApprovalRequestId,
|
||||||
InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole, OperationId,
|
AuthProfile, HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId,
|
||||||
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Secret, SecretId,
|
MembershipRole, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
||||||
SecretVersion, Target, UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId,
|
PlatformApiKeyStatus, Secret, SecretId, SecretVersion, Target, UsageRollup, User, UserId,
|
||||||
|
UserSessionId, Workspace, WorkspaceId,
|
||||||
};
|
};
|
||||||
use serde::{Serialize, de::DeserializeOwned};
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -28,10 +30,11 @@ pub use pool_config::{PostgresPoolConfig, PostgresPoolConfigError};
|
|||||||
use crate::{
|
use crate::{
|
||||||
error::RegistryError,
|
error::RegistryError,
|
||||||
model::{
|
model::{
|
||||||
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentDraftVersionRequest,
|
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord,
|
||||||
CreateAgentRequest, CreateImportJobRequest, CreateInvitationRequest,
|
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
||||||
|
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||||
DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, InvitationRecord,
|
DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, InvitationRecord,
|
||||||
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
||||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ use super::common::*;
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig, AuthConfig,
|
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig,
|
||||||
AuthKind, AuthProfile, ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft,
|
ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthConfig, AuthKind, AuthProfile,
|
||||||
GeneratedDraftStatus, HttpMethod, InvocationLog, MembershipRole, OperationId,
|
ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft, GeneratedDraftStatus, HttpMethod,
|
||||||
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind,
|
InvocationLog, MembershipRole, OperationApprovalRiskLevel, OperationId, OperationSecurityLevel,
|
||||||
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples,
|
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
|
||||||
SecretId, Target, ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState,
|
PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretId, Target,
|
||||||
Workspace, WorkspaceId,
|
ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState, Workspace, WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_mapping::{MappingRule, MappingSet};
|
use crank_mapping::{MappingRule, MappingSet};
|
||||||
use crank_schema::{Schema, SchemaKind};
|
use crank_schema::{Schema, SchemaKind};
|
||||||
@@ -20,13 +20,13 @@ use sqlx::{Executor, PgPool, postgres::PgPoolOptions};
|
|||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
|
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
CreateAgentRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
|
CreateAgentRequest, CreateApprovalRequest, CreateInvocationLogRequest,
|
||||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
|
CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||||
DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry,
|
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
|
||||||
PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind,
|
OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest,
|
||||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
PublishRequest, RegistryError, RegistryOperation, SampleKind, SaveAuthProfileRequest,
|
||||||
UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId,
|
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest,
|
||||||
YamlImportJobStatus,
|
WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn test_workspace_id() -> WorkspaceId {
|
fn test_workspace_id() -> WorkspaceId {
|
||||||
@@ -455,3 +455,126 @@ async fn manages_platform_api_key_read_paths() {
|
|||||||
|
|
||||||
database.cleanup().await;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user