Add approval request HTTP surface

This commit is contained in:
github-ops
2026-06-24 11:39:39 +00:00
parent d83ab541d9
commit 8a1cc1746f
16 changed files with 889 additions and 69 deletions
@@ -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(
&registry,
"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(&registry, "sales-human-approval", "approval-http").await;
let mcp_key = create_platform_api_key(
&registry,
"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]
async fn rejects_tool_call_with_read_only_platform_api_key() {
let registry = test_registry().await;
+5 -1
View File
@@ -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
+2 -1
View File
@@ -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) {
+4 -2
View File
@@ -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': 'Создать ключ подтверждения',
+49
View File
@@ -27,6 +27,41 @@ pub(super) async fn require_machine_access(
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> {
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,
+151 -6
View File
@@ -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<String>,
}
#[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<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(
Path(path): Path<AgentRoutePath>,
State(state): State<Arc<AppState>>,
+39
View File
@@ -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>,
}
+1
View File
@@ -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);
+9 -5
View File
@@ -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,
+24 -22
View File
@@ -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,
+29
View File
@@ -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,
+28 -5
View File
@@ -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<Value>,
pub decision_note: Option<&'a str>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreateSecretRequest<'a> {
pub secret: &'a Secret,
@@ -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<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(
&self,
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"),
},
})
}
+11 -8
View File
@@ -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,
@@ -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;
}