Add approval request HTTP surface
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
Reference in New Issue
Block a user