feat: resolve upstream auth at runtime
This commit is contained in:
Generated
+3
-1
@@ -6,7 +6,6 @@ version = 4
|
|||||||
name = "admin-api"
|
name = "admin-api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
|
||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
@@ -473,7 +472,9 @@ dependencies = [
|
|||||||
name = "crank-runtime"
|
name = "crank-runtime"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"crank-adapter-graphql",
|
"crank-adapter-graphql",
|
||||||
"crank-adapter-grpc",
|
"crank-adapter-grpc",
|
||||||
"crank-adapter-rest",
|
"crank-adapter-rest",
|
||||||
@@ -485,6 +486,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/auth-profile-secret-resolution`
|
### `feat/runtime-upstream-auth`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- `AuthConfig` references real `SecretId` values instead of string placeholders
|
- runtime resolves `auth_profile_ref` into decrypted secret-backed auth before upstream execution
|
||||||
- auth profile create flow validates all referenced secrets inside the workspace
|
- `admin-api` test-runs and `mcp-server` tool calls use the same auth-aware runtime path
|
||||||
- secret delete is rejected while an auth profile still references the secret
|
- secret usage updates `last_used_at` when auth-backed execution succeeds at resolution time
|
||||||
- tests cover missing-secret validation and reference-protected secret deletion
|
- tests and clippy pass for `crank-runtime`, `crank-registry`, `admin-api`, and `mcp-server`
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/runtime-upstream-auth`
|
- `feat/secrets-ui`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ rust-version.workspace = true
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
aes-gcm.workspace = true
|
|
||||||
argon2.workspace = true
|
argon2.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
axum-extra.workspace = true
|
axum-extra.workspace = true
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use crank_mapping::{MappingRule, MappingSet};
|
use crank_mapping::{MappingRule, MappingSet};
|
||||||
use crank_registry::PostgresRegistry;
|
use crank_registry::PostgresRegistry;
|
||||||
|
use crank_runtime::SecretCrypto;
|
||||||
use crank_schema::{Schema, SchemaKind};
|
use crank_schema::{Schema, SchemaKind};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
@@ -236,7 +237,6 @@ mod tests {
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::build_app,
|
app::build_app,
|
||||||
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
||||||
secret_crypto::SecretCrypto,
|
|
||||||
service::{AdminService, OperationPayload},
|
service::{AdminService, OperationPayload},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -251,5 +251,11 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
|
|||||||
RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error",
|
RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error",
|
||||||
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
|
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
|
||||||
RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error",
|
RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error",
|
||||||
|
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
|
||||||
|
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||||
|
"runtime_secret_error"
|
||||||
|
}
|
||||||
|
RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error",
|
||||||
|
RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ mod app;
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod error;
|
mod error;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod secret_crypto;
|
|
||||||
mod service;
|
mod service;
|
||||||
mod state;
|
mod state;
|
||||||
mod storage;
|
mod storage;
|
||||||
@@ -16,10 +15,10 @@ use tracing::info;
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::build_app,
|
app::build_app,
|
||||||
auth::{AuthSettings, BootstrapAdminConfig},
|
auth::{AuthSettings, BootstrapAdminConfig},
|
||||||
secret_crypto::SecretCrypto,
|
|
||||||
service::AdminService,
|
service::AdminService,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
use crank_runtime::SecretCrypto;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ use crank_registry::{
|
|||||||
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
||||||
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
|
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
|
||||||
};
|
};
|
||||||
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
|
use crank_runtime::{
|
||||||
|
PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, RuntimeOperation, SecretCrypto,
|
||||||
|
};
|
||||||
use crank_schema::Schema;
|
use crank_schema::Schema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -46,7 +48,6 @@ use crate::{
|
|||||||
hash_session_secret, verify_password,
|
hash_session_secret, verify_password,
|
||||||
},
|
},
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
secret_crypto::SecretCrypto,
|
|
||||||
storage::LocalArtifactStorage,
|
storage::LocalArtifactStorage,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1918,8 +1919,18 @@ impl AdminService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let resolved_auth = self
|
||||||
|
.resolve_operation_auth(workspace_id, &runtime.execution_config)
|
||||||
|
.await;
|
||||||
let started_at = std::time::Instant::now();
|
let started_at = std::time::Instant::now();
|
||||||
match self.runtime.execute(&runtime, &payload.input).await {
|
match match resolved_auth {
|
||||||
|
Ok(resolved_auth) => {
|
||||||
|
self.runtime
|
||||||
|
.execute_with_auth(&runtime, &payload.input, resolved_auth.as_ref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
} {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
let duration_ms =
|
let duration_ms =
|
||||||
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||||
@@ -1973,6 +1984,78 @@ impl AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_operation_auth(
|
||||||
|
&self,
|
||||||
|
workspace_id: &WorkspaceId,
|
||||||
|
execution_config: &crank_core::ExecutionConfig,
|
||||||
|
) -> Result<Option<ResolvedAuth>, RuntimeError> {
|
||||||
|
let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_profile = self
|
||||||
|
.registry
|
||||||
|
.get_auth_profile(workspace_id, auth_profile_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
details: error.to_string(),
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
||||||
|
auth_profile_id: auth_profile_id.as_str().to_owned(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.resolve_auth_profile(workspace_id, &auth_profile)
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_auth_profile(
|
||||||
|
&self,
|
||||||
|
workspace_id: &WorkspaceId,
|
||||||
|
auth_profile: &AuthProfile,
|
||||||
|
) -> Result<ResolvedAuth, RuntimeError> {
|
||||||
|
let mut secrets = BTreeMap::new();
|
||||||
|
let used_at = now_string().map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
details: error.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for secret_id in auth_profile.config.secret_ids() {
|
||||||
|
let secret = self
|
||||||
|
.registry
|
||||||
|
.get_secret(workspace_id, secret_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
details: error.to_string(),
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| RuntimeError::MissingSecret {
|
||||||
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
|
})?;
|
||||||
|
let version = self
|
||||||
|
.registry
|
||||||
|
.get_current_secret_version(workspace_id, secret_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
details: error.to_string(),
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
||||||
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
|
version: secret.secret.current_version,
|
||||||
|
})?;
|
||||||
|
let plaintext = self
|
||||||
|
.secret_crypto
|
||||||
|
.decrypt(&version.secret_version.ciphertext)?;
|
||||||
|
self.registry
|
||||||
|
.touch_secret(workspace_id, secret_id, &used_at)
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
details: error.to_string(),
|
||||||
|
})?;
|
||||||
|
secrets.insert(secret_id.clone(), plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
ResolvedAuth::from_profile(auth_profile, &secrets)
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn list_auth_profiles(
|
pub async fn list_auth_profiles(
|
||||||
&self,
|
&self,
|
||||||
@@ -2032,7 +2115,10 @@ impl AdminService {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
last_used_at: None,
|
last_used_at: None,
|
||||||
};
|
};
|
||||||
let ciphertext = self.secret_crypto.encrypt(&payload.value)?;
|
let ciphertext = self
|
||||||
|
.secret_crypto
|
||||||
|
.encrypt(&payload.value)
|
||||||
|
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||||
|
|
||||||
self.registry
|
self.registry
|
||||||
.create_secret(CreateSecretRequest {
|
.create_secret(CreateSecretRequest {
|
||||||
@@ -2061,7 +2147,10 @@ impl AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = now_string()?;
|
let now = now_string()?;
|
||||||
let ciphertext = self.secret_crypto.encrypt(&payload.value)?;
|
let ciphertext = self
|
||||||
|
.secret_crypto
|
||||||
|
.encrypt(&payload.value)
|
||||||
|
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||||
self.registry
|
self.registry
|
||||||
.rotate_secret(RotateSecretRequest {
|
.rotate_secret(RotateSecretRequest {
|
||||||
workspace_id,
|
workspace_id,
|
||||||
@@ -4200,6 +4289,12 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
|
|||||||
RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error",
|
RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error",
|
||||||
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
||||||
RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error",
|
RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error",
|
||||||
|
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
|
||||||
|
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||||
|
"secret_not_found"
|
||||||
|
}
|
||||||
|
RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error",
|
||||||
|
RuntimeError::SecretCrypto { .. } => "secret_crypto_error",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+143
-15
@@ -1,4 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
convert::Infallible,
|
convert::Infallible,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
@@ -19,15 +20,16 @@ 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::{
|
||||||
AsyncJobHandle, AsyncJobId, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource,
|
AsyncJobHandle, AsyncJobId, AuthProfile, InvocationLevel, InvocationLog, InvocationLogId,
|
||||||
InvocationStatus, JobStatus, PlatformApiKeyScope, StreamSession, StreamSessionId, StreamStatus,
|
InvocationSource, InvocationStatus, JobStatus, PlatformApiKeyScope, SecretId, StreamSession,
|
||||||
|
StreamSessionId, StreamStatus,
|
||||||
};
|
};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
CreateAsyncJobRequest, CreateInvocationLogRequest, CreateStreamSessionRequest,
|
CreateAsyncJobRequest, CreateInvocationLogRequest, CreateStreamSessionRequest,
|
||||||
PostgresRegistry, PublishedAgentTool, UpdateAsyncJobStatusRequest,
|
PostgresRegistry, PublishedAgentTool, UpdateAsyncJobStatusRequest,
|
||||||
UpdateStreamSessionStateRequest,
|
UpdateStreamSessionStateRequest,
|
||||||
};
|
};
|
||||||
use crank_runtime::{RuntimeError, RuntimeExecutor, RuntimeOperation};
|
use crank_runtime::{ResolvedAuth, RuntimeError, RuntimeExecutor, RuntimeOperation, SecretCrypto};
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -58,6 +60,7 @@ pub struct AppState {
|
|||||||
registry: PostgresRegistry,
|
registry: PostgresRegistry,
|
||||||
catalog: PublishedToolCatalog,
|
catalog: PublishedToolCatalog,
|
||||||
runtime: RuntimeExecutor,
|
runtime: RuntimeExecutor,
|
||||||
|
secret_crypto: SecretCrypto,
|
||||||
sessions: SessionStore,
|
sessions: SessionStore,
|
||||||
allowed_origins: AllowedOrigins,
|
allowed_origins: AllowedOrigins,
|
||||||
}
|
}
|
||||||
@@ -118,11 +121,13 @@ pub fn build_app(
|
|||||||
registry: PostgresRegistry,
|
registry: PostgresRegistry,
|
||||||
refresh_interval: Duration,
|
refresh_interval: Duration,
|
||||||
public_base_url: Option<String>,
|
public_base_url: Option<String>,
|
||||||
|
secret_crypto: SecretCrypto,
|
||||||
) -> Router {
|
) -> Router {
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
registry: registry.clone(),
|
registry: registry.clone(),
|
||||||
catalog: PublishedToolCatalog::new(registry, refresh_interval),
|
catalog: PublishedToolCatalog::new(registry, refresh_interval),
|
||||||
runtime: RuntimeExecutor::new(),
|
runtime: RuntimeExecutor::new(),
|
||||||
|
secret_crypto,
|
||||||
sessions: SessionStore::new(),
|
sessions: SessionStore::new(),
|
||||||
allowed_origins: AllowedOrigins::new(public_base_url),
|
allowed_origins: AllowedOrigins::new(public_base_url),
|
||||||
});
|
});
|
||||||
@@ -509,6 +514,87 @@ struct StoredSessionState {
|
|||||||
batch_size: usize,
|
batch_size: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_operation_auth(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
workspace_id: &crank_core::WorkspaceId,
|
||||||
|
execution_config: &crank_core::ExecutionConfig,
|
||||||
|
) -> Result<Option<ResolvedAuth>, RuntimeError> {
|
||||||
|
resolve_runtime_auth_for_task(
|
||||||
|
&state.registry,
|
||||||
|
&state.secret_crypto,
|
||||||
|
workspace_id,
|
||||||
|
execution_config,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_runtime_auth_for_task(
|
||||||
|
registry: &PostgresRegistry,
|
||||||
|
secret_crypto: &SecretCrypto,
|
||||||
|
workspace_id: &crank_core::WorkspaceId,
|
||||||
|
execution_config: &crank_core::ExecutionConfig,
|
||||||
|
) -> Result<Option<ResolvedAuth>, RuntimeError> {
|
||||||
|
let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_profile = registry
|
||||||
|
.get_auth_profile(workspace_id, auth_profile_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
details: error.to_string(),
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
||||||
|
auth_profile_id: auth_profile_id.as_str().to_owned(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
resolve_auth_profile(registry, secret_crypto, workspace_id, &auth_profile)
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_auth_profile(
|
||||||
|
registry: &PostgresRegistry,
|
||||||
|
secret_crypto: &SecretCrypto,
|
||||||
|
workspace_id: &crank_core::WorkspaceId,
|
||||||
|
auth_profile: &AuthProfile,
|
||||||
|
) -> Result<ResolvedAuth, RuntimeError> {
|
||||||
|
let mut secrets = BTreeMap::new();
|
||||||
|
let used_at = now_rfc3339();
|
||||||
|
|
||||||
|
for secret_id in auth_profile.config.secret_ids() {
|
||||||
|
let secret = registry
|
||||||
|
.get_secret(workspace_id, secret_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
details: error.to_string(),
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| RuntimeError::MissingSecret {
|
||||||
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
|
})?;
|
||||||
|
let version = registry
|
||||||
|
.get_current_secret_version(workspace_id, secret_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
details: error.to_string(),
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
||||||
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
|
version: secret.secret.current_version,
|
||||||
|
})?;
|
||||||
|
let plaintext = secret_crypto.decrypt(&version.secret_version.ciphertext)?;
|
||||||
|
registry
|
||||||
|
.touch_secret(workspace_id, secret_id, &used_at)
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
details: error.to_string(),
|
||||||
|
})?;
|
||||||
|
secrets.insert(SecretId::new(secret_id.as_str()), plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
ResolvedAuth::from_profile(auth_profile, &secrets)
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_base_tool_call(
|
async fn handle_base_tool_call(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
session: &SessionState,
|
session: &SessionState,
|
||||||
@@ -528,11 +614,13 @@ async fn handle_base_tool_call(
|
|||||||
.map(|streaming| streaming.mode),
|
.map(|streaming| streaming.mode),
|
||||||
Some(crank_core::ExecutionMode::Window)
|
Some(crank_core::ExecutionMode::Window)
|
||||||
);
|
);
|
||||||
|
let resolved_auth =
|
||||||
|
resolve_operation_auth(&state, &tool.workspace_id, &operation.execution_config).await;
|
||||||
|
|
||||||
let result = if is_window_mode {
|
let result = match resolved_auth {
|
||||||
state
|
Ok(resolved_auth) if is_window_mode => state
|
||||||
.runtime
|
.runtime
|
||||||
.execute_window(&operation, &arguments)
|
.execute_window_with_auth(&operation, &arguments, resolved_auth.as_ref())
|
||||||
.await
|
.await
|
||||||
.map(|output| {
|
.map(|output| {
|
||||||
json!({
|
json!({
|
||||||
@@ -543,9 +631,14 @@ async fn handle_base_tool_call(
|
|||||||
"truncated": output.truncated,
|
"truncated": output.truncated,
|
||||||
"has_more": output.has_more,
|
"has_more": output.has_more,
|
||||||
})
|
})
|
||||||
})
|
}),
|
||||||
} else {
|
Ok(resolved_auth) => {
|
||||||
state.runtime.execute(&operation, &arguments).await
|
state
|
||||||
|
.runtime
|
||||||
|
.execute_with_auth(&operation, &arguments, resolved_auth.as_ref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -619,11 +712,25 @@ async fn handle_session_start_call(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
match state
|
let resolved_auth = resolve_operation_auth(
|
||||||
.runtime
|
&state,
|
||||||
.execute_session_seed(&runtime_operation, &arguments)
|
&tool.workspace_id,
|
||||||
.await
|
&runtime_operation.execution_config,
|
||||||
{
|
)
|
||||||
|
.await;
|
||||||
|
match match resolved_auth {
|
||||||
|
Ok(resolved_auth) => {
|
||||||
|
state
|
||||||
|
.runtime
|
||||||
|
.execute_session_seed_with_auth(
|
||||||
|
&runtime_operation,
|
||||||
|
&arguments,
|
||||||
|
resolved_auth.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
} {
|
||||||
Ok(seed) => {
|
Ok(seed) => {
|
||||||
let batch_size = streaming.max_items.unwrap_or(10).max(1) as usize;
|
let batch_size = streaming.max_items.unwrap_or(10).max(1) as usize;
|
||||||
let preview_count = seed.items.len().min(batch_size);
|
let preview_count = seed.items.len().min(batch_size);
|
||||||
@@ -983,13 +1090,28 @@ async fn handle_async_job_start_call(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let registry = state.registry.clone();
|
let registry = state.registry.clone();
|
||||||
|
let secret_crypto = state.secret_crypto.clone();
|
||||||
let tool_for_task = tool.clone();
|
let tool_for_task = tool.clone();
|
||||||
let arguments_for_task = arguments.clone();
|
let arguments_for_task = arguments.clone();
|
||||||
let job_id = job.id.clone();
|
let job_id = job.id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let runtime = RuntimeExecutor::new();
|
let runtime = RuntimeExecutor::new();
|
||||||
let task_operation = runtime_operation(&tool_for_task);
|
let task_operation = runtime_operation(&tool_for_task);
|
||||||
let result = runtime.execute(&task_operation, &arguments_for_task).await;
|
let resolved_auth = resolve_runtime_auth_for_task(
|
||||||
|
®istry,
|
||||||
|
&secret_crypto,
|
||||||
|
&tool_for_task.workspace_id,
|
||||||
|
&task_operation.execution_config,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let result = match resolved_auth {
|
||||||
|
Ok(resolved_auth) => {
|
||||||
|
runtime
|
||||||
|
.execute_with_auth(&task_operation, &arguments_for_task, resolved_auth.as_ref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
};
|
||||||
let finished_at = now_rfc3339();
|
let finished_at = now_rfc3339();
|
||||||
|
|
||||||
let update_result = match result {
|
let update_result = match result {
|
||||||
@@ -1732,6 +1854,12 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
|
|||||||
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
||||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_error",
|
RuntimeError::InvalidPreparedRequest { .. } => "runtime_error",
|
||||||
RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error",
|
RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error",
|
||||||
|
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
|
||||||
|
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||||
|
"secret_not_found"
|
||||||
|
}
|
||||||
|
RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error",
|
||||||
|
RuntimeError::SecretCrypto { .. } => "secret_crypto_error",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-17
@@ -6,6 +6,7 @@ mod session;
|
|||||||
use std::{env, net::SocketAddr, time::Duration};
|
use std::{env, net::SocketAddr, time::Duration};
|
||||||
|
|
||||||
use crank_registry::PostgresRegistry;
|
use crank_registry::PostgresRegistry;
|
||||||
|
use crank_runtime::SecretCrypto;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.unwrap_or_else(|| Duration::from_secs(5));
|
.unwrap_or_else(|| Duration::from_secs(5));
|
||||||
let socket_addr: SocketAddr = bind_addr.parse()?;
|
let socket_addr: SocketAddr = bind_addr.parse()?;
|
||||||
let registry = PostgresRegistry::connect(&database_url).await?;
|
let registry = PostgresRegistry::connect(&database_url).await?;
|
||||||
let app = build_app(registry, refresh_interval, public_base_url);
|
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||||
|
let app = build_app(registry, refresh_interval, public_base_url, secret_crypto);
|
||||||
let listener = TcpListener::bind(socket_addr).await?;
|
let listener = TcpListener::bind(socket_addr).await?;
|
||||||
|
|
||||||
info!("mcp-server listening on {}", socket_addr);
|
info!("mcp-server listening on {}", socket_addr);
|
||||||
@@ -68,6 +70,7 @@ mod tests {
|
|||||||
CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry,
|
CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry,
|
||||||
PublishAgentRequest, PublishRequest,
|
PublishAgentRequest, PublishRequest,
|
||||||
};
|
};
|
||||||
|
use crank_runtime::SecretCrypto;
|
||||||
use crank_schema::{Schema, SchemaKind};
|
use crank_schema::{Schema, SchemaKind};
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -86,6 +89,19 @@ mod tests {
|
|||||||
"default"
|
"default"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_test_app(
|
||||||
|
registry: PostgresRegistry,
|
||||||
|
refresh_interval: Duration,
|
||||||
|
public_base_url: Option<String>,
|
||||||
|
) -> axum::Router {
|
||||||
|
build_app(
|
||||||
|
registry,
|
||||||
|
refresh_interval,
|
||||||
|
public_base_url,
|
||||||
|
SecretCrypto::new("test-master-key").unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn initializes_lists_and_calls_published_tool_via_mcp() {
|
async fn initializes_lists_and_calls_published_tool_via_mcp() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
@@ -114,7 +130,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -220,7 +236,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -284,7 +300,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -326,7 +342,7 @@ mod tests {
|
|||||||
publish_agent_with_bindings(®istry, "sales-init", vec![]).await;
|
publish_agent_with_bindings(®istry, "sales-init", vec![]).await;
|
||||||
let api_key =
|
let api_key =
|
||||||
create_platform_api_key(®istry, "mcp-init", &[PlatformApiKeyScope::Read]).await;
|
create_platform_api_key(®istry, "mcp-init", &[PlatformApiKeyScope::Read]).await;
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -388,7 +404,7 @@ mod tests {
|
|||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -438,7 +454,7 @@ mod tests {
|
|||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -489,7 +505,7 @@ mod tests {
|
|||||||
&[PlatformApiKeyScope::Read],
|
&[PlatformApiKeyScope::Read],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -518,7 +534,7 @@ mod tests {
|
|||||||
&[PlatformApiKeyScope::Read],
|
&[PlatformApiKeyScope::Read],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -562,7 +578,7 @@ mod tests {
|
|||||||
&[PlatformApiKeyScope::Read],
|
&[PlatformApiKeyScope::Read],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -589,7 +605,7 @@ mod tests {
|
|||||||
async fn refreshes_published_tools_without_restart() {
|
async fn refreshes_published_tools_without_restart() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
let upstream_base_url = spawn_upstream_server().await;
|
let upstream_base_url = spawn_upstream_server().await;
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -700,7 +716,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -747,7 +763,7 @@ mod tests {
|
|||||||
async fn rejects_initialize_without_platform_api_key() {
|
async fn rejects_initialize_without_platform_api_key() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
publish_agent_with_bindings(®istry, "sales-auth", vec![]).await;
|
publish_agent_with_bindings(®istry, "sales-auth", vec![]).await;
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -796,7 +812,7 @@ mod tests {
|
|||||||
let api_key =
|
let api_key =
|
||||||
create_platform_api_key(®istry, "mcp-read", &[PlatformApiKeyScope::Read]).await;
|
create_platform_api_key(®istry, "mcp-read", &[PlatformApiKeyScope::Read]).await;
|
||||||
|
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -857,7 +873,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -1001,7 +1017,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
@@ -1148,7 +1164,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let base_url = spawn_mcp_server(build_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
Some("https://crank.example.com".to_owned()),
|
Some("https://crank.example.com".to_owned()),
|
||||||
|
|||||||
@@ -710,6 +710,32 @@ impl PostgresRegistry {
|
|||||||
row.as_ref().map(map_secret_record).transpose()
|
row.as_ref().map(map_secret_record).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_current_secret_version(
|
||||||
|
&self,
|
||||||
|
workspace_id: &WorkspaceId,
|
||||||
|
secret_id: &SecretId,
|
||||||
|
) -> Result<Option<SecretVersionRecord>, RegistryError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"select
|
||||||
|
sv.secret_id,
|
||||||
|
sv.version,
|
||||||
|
sv.ciphertext,
|
||||||
|
sv.key_version,
|
||||||
|
to_char(sv.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
||||||
|
sv.created_by
|
||||||
|
from secrets s
|
||||||
|
join secret_versions sv
|
||||||
|
on sv.secret_id = s.id and sv.version = s.current_version
|
||||||
|
where s.workspace_id = $1 and s.id = $2",
|
||||||
|
)
|
||||||
|
.bind(workspace_id.as_str())
|
||||||
|
.bind(secret_id.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
row.as_ref().map(map_secret_version_record).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_secret(
|
pub async fn create_secret(
|
||||||
&self,
|
&self,
|
||||||
request: CreateSecretRequest<'_>,
|
request: CreateSecretRequest<'_>,
|
||||||
@@ -863,6 +889,32 @@ impl PostgresRegistry {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn touch_secret(
|
||||||
|
&self,
|
||||||
|
workspace_id: &WorkspaceId,
|
||||||
|
secret_id: &SecretId,
|
||||||
|
used_at: &str,
|
||||||
|
) -> Result<(), RegistryError> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"update secrets
|
||||||
|
set last_used_at = $3::timestamptz
|
||||||
|
where workspace_id = $1 and id = $2",
|
||||||
|
)
|
||||||
|
.bind(workspace_id.as_str())
|
||||||
|
.bind(secret_id.as_str())
|
||||||
|
.bind(used_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(RegistryError::SecretNotFound {
|
||||||
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_stream_session(
|
pub async fn create_stream_session(
|
||||||
&self,
|
&self,
|
||||||
request: CreateStreamSessionRequest<'_>,
|
request: CreateStreamSessionRequest<'_>,
|
||||||
@@ -3812,6 +3864,19 @@ fn map_secret_record(row: &PgRow) -> Result<SecretRecord, RegistryError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_secret_version_record(row: &PgRow) -> Result<SecretVersionRecord, RegistryError> {
|
||||||
|
Ok(SecretVersionRecord {
|
||||||
|
secret_version: SecretVersion {
|
||||||
|
secret_id: SecretId::new(row.try_get::<String, _>("secret_id")?),
|
||||||
|
version: from_db_version(row.try_get::<i32, _>("version")?, "version")?,
|
||||||
|
ciphertext: row.try_get("ciphertext")?,
|
||||||
|
key_version: row.try_get("key_version")?,
|
||||||
|
created_at: row.try_get("created_at")?,
|
||||||
|
created_by: row.try_get::<Option<String>, _>("created_by")?.map(UserId::new),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn map_stream_session(row: &PgRow) -> Result<StreamSession, RegistryError> {
|
fn map_stream_session(row: &PgRow) -> Result<StreamSession, RegistryError> {
|
||||||
Ok(StreamSession {
|
Ok(StreamSession {
|
||||||
id: StreamSessionId::new(row.try_get::<String, _>("id")?),
|
id: StreamSessionId::new(row.try_get::<String, _>("id")?),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ rust-version.workspace = true
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
aes-gcm.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
crank-adapter-graphql = { path = "../crank-adapter-graphql" }
|
crank-adapter-graphql = { path = "../crank-adapter-graphql" }
|
||||||
crank-adapter-grpc = { path = "../crank-adapter-grpc" }
|
crank-adapter-grpc = { path = "../crank-adapter-grpc" }
|
||||||
crank-adapter-rest = { path = "../crank-adapter-rest" }
|
crank-adapter-rest = { path = "../crank-adapter-rest" }
|
||||||
@@ -16,6 +18,7 @@ crank-mapping = { path = "../crank-mapping" }
|
|||||||
crank-schema = { path = "../crank-schema" }
|
crank-schema = { path = "../crank-schema" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
use crank_core::{AuthConfig, AuthProfile, SecretId};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{PreparedRequest, RuntimeError};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ResolvedAuth {
|
||||||
|
Bearer { header_name: String, token: String },
|
||||||
|
Basic { username: String, password: String },
|
||||||
|
ApiKeyHeader { header_name: String, value: String },
|
||||||
|
ApiKeyQuery { param_name: String, value: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedAuth {
|
||||||
|
pub fn from_profile(
|
||||||
|
profile: &AuthProfile,
|
||||||
|
secrets: &BTreeMap<SecretId, Value>,
|
||||||
|
) -> Result<Self, RuntimeError> {
|
||||||
|
match &profile.config {
|
||||||
|
AuthConfig::Bearer(config) => Ok(Self::Bearer {
|
||||||
|
header_name: config.header_name.clone(),
|
||||||
|
token: extract_secret_string(secrets, &config.secret_id, &["token", "value"])?,
|
||||||
|
}),
|
||||||
|
AuthConfig::Basic(config) => Ok(Self::Basic {
|
||||||
|
username: extract_secret_string(
|
||||||
|
secrets,
|
||||||
|
&config.username_secret_id,
|
||||||
|
&["username", "value"],
|
||||||
|
)?,
|
||||||
|
password: extract_secret_string(
|
||||||
|
secrets,
|
||||||
|
&config.password_secret_id,
|
||||||
|
&["password", "value"],
|
||||||
|
)?,
|
||||||
|
}),
|
||||||
|
AuthConfig::ApiKeyHeader(config) => Ok(Self::ApiKeyHeader {
|
||||||
|
header_name: config.header_name.clone(),
|
||||||
|
value: extract_secret_string(secrets, &config.secret_id, &["token", "value"])?,
|
||||||
|
}),
|
||||||
|
AuthConfig::ApiKeyQuery(config) => Ok(Self::ApiKeyQuery {
|
||||||
|
param_name: config.param_name.clone(),
|
||||||
|
value: extract_secret_string(secrets, &config.secret_id, &["token", "value"])?,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(&self, prepared_request: &mut PreparedRequest) {
|
||||||
|
match self {
|
||||||
|
Self::Bearer { header_name, token } => {
|
||||||
|
prepared_request
|
||||||
|
.headers
|
||||||
|
.insert(header_name.clone(), format!("Bearer {token}"));
|
||||||
|
}
|
||||||
|
Self::Basic { username, password } => {
|
||||||
|
let credentials = STANDARD.encode(format!("{username}:{password}"));
|
||||||
|
prepared_request
|
||||||
|
.headers
|
||||||
|
.insert("Authorization".to_owned(), format!("Basic {credentials}"));
|
||||||
|
}
|
||||||
|
Self::ApiKeyHeader { header_name, value } => {
|
||||||
|
prepared_request
|
||||||
|
.headers
|
||||||
|
.insert(header_name.clone(), value.clone());
|
||||||
|
}
|
||||||
|
Self::ApiKeyQuery { param_name, value } => {
|
||||||
|
prepared_request
|
||||||
|
.query_params
|
||||||
|
.insert(param_name.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_secret_string(
|
||||||
|
secrets: &BTreeMap<SecretId, Value>,
|
||||||
|
secret_id: &SecretId,
|
||||||
|
preferred_fields: &[&str],
|
||||||
|
) -> Result<String, RuntimeError> {
|
||||||
|
let Some(value) = secrets.get(secret_id) else {
|
||||||
|
return Err(RuntimeError::MissingSecret {
|
||||||
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(text) = value.as_str() {
|
||||||
|
return Ok(text.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(object) = value.as_object() else {
|
||||||
|
return Err(RuntimeError::InvalidAuthSecretValue {
|
||||||
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
|
details: "secret payload must be a string or object".to_owned(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for field in preferred_fields {
|
||||||
|
if let Some(text) = object.get(*field).and_then(Value::as_str) {
|
||||||
|
return Ok(text.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(RuntimeError::InvalidAuthSecretValue {
|
||||||
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
|
details: format!(
|
||||||
|
"secret payload must contain one of: {}",
|
||||||
|
preferred_fields.join(", ")
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crank_core::{
|
||||||
|
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthKind, AuthProfile,
|
||||||
|
BasicAuthConfig, BearerAuthConfig, SecretId, WorkspaceId,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{PreparedRequest, auth::ResolvedAuth};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applies_bearer_auth_to_headers() {
|
||||||
|
let auth = ResolvedAuth::from_profile(
|
||||||
|
&AuthProfile {
|
||||||
|
id: "auth_01".into(),
|
||||||
|
workspace_id: WorkspaceId::new("ws_default"),
|
||||||
|
name: "bearer".to_owned(),
|
||||||
|
kind: AuthKind::Bearer,
|
||||||
|
config: AuthConfig::Bearer(BearerAuthConfig {
|
||||||
|
header_name: "Authorization".to_owned(),
|
||||||
|
secret_id: SecretId::new("secret_token"),
|
||||||
|
}),
|
||||||
|
created_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||||
|
updated_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||||
|
},
|
||||||
|
&BTreeMap::from([(SecretId::new("secret_token"), json!({"token":"abc"}))]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut request = PreparedRequest::default();
|
||||||
|
|
||||||
|
auth.apply(&mut request);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request.headers.get("Authorization").map(String::as_str),
|
||||||
|
Some("Bearer abc")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applies_basic_auth_to_headers() {
|
||||||
|
let auth = ResolvedAuth::from_profile(
|
||||||
|
&AuthProfile {
|
||||||
|
id: "auth_01".into(),
|
||||||
|
workspace_id: WorkspaceId::new("ws_default"),
|
||||||
|
name: "basic".to_owned(),
|
||||||
|
kind: AuthKind::Basic,
|
||||||
|
config: AuthConfig::Basic(BasicAuthConfig {
|
||||||
|
username_secret_id: SecretId::new("secret_user"),
|
||||||
|
password_secret_id: SecretId::new("secret_pass"),
|
||||||
|
}),
|
||||||
|
created_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||||
|
updated_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||||
|
},
|
||||||
|
&BTreeMap::from([
|
||||||
|
(SecretId::new("secret_user"), json!({"username":"demo"})),
|
||||||
|
(SecretId::new("secret_pass"), json!({"password":"s3cr3t"})),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut request = PreparedRequest::default();
|
||||||
|
|
||||||
|
auth.apply(&mut request);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request.headers.get("Authorization").map(String::as_str),
|
||||||
|
Some("Basic ZGVtbzpzM2NyM3Q=")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applies_api_key_header_auth() {
|
||||||
|
let auth = ResolvedAuth::from_profile(
|
||||||
|
&AuthProfile {
|
||||||
|
id: "auth_01".into(),
|
||||||
|
workspace_id: WorkspaceId::new("ws_default"),
|
||||||
|
name: "api-header".to_owned(),
|
||||||
|
kind: AuthKind::ApiKeyHeader,
|
||||||
|
config: AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig {
|
||||||
|
header_name: "X-Api-Key".to_owned(),
|
||||||
|
secret_id: SecretId::new("secret_api_key"),
|
||||||
|
}),
|
||||||
|
created_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||||
|
updated_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||||
|
},
|
||||||
|
&BTreeMap::from([(SecretId::new("secret_api_key"), json!("key-123"))]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut request = PreparedRequest::default();
|
||||||
|
|
||||||
|
auth.apply(&mut request);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request.headers.get("X-Api-Key").map(String::as_str),
|
||||||
|
Some("key-123")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applies_api_key_query_auth() {
|
||||||
|
let auth = ResolvedAuth::from_profile(
|
||||||
|
&AuthProfile {
|
||||||
|
id: "auth_01".into(),
|
||||||
|
workspace_id: WorkspaceId::new("ws_default"),
|
||||||
|
name: "api-query".to_owned(),
|
||||||
|
kind: AuthKind::ApiKeyQuery,
|
||||||
|
config: AuthConfig::ApiKeyQuery(ApiKeyQueryAuthConfig {
|
||||||
|
param_name: "api_key".to_owned(),
|
||||||
|
secret_id: SecretId::new("secret_api_key"),
|
||||||
|
}),
|
||||||
|
created_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||||
|
updated_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||||
|
},
|
||||||
|
&BTreeMap::from([(SecretId::new("secret_api_key"), json!({"value":"key-123"}))]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut request = PreparedRequest::default();
|
||||||
|
|
||||||
|
auth.apply(&mut request);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request.query_params.get("api_key").map(String::as_str),
|
||||||
|
Some("key-123")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,4 +37,14 @@ pub enum RuntimeError {
|
|||||||
InvalidPreparedRequest { details: String },
|
InvalidPreparedRequest { details: String },
|
||||||
#[error("invalid streaming payload: {details}")]
|
#[error("invalid streaming payload: {details}")]
|
||||||
InvalidStreamingPayload { details: String },
|
InvalidStreamingPayload { details: String },
|
||||||
|
#[error("auth profile {auth_profile_id} was not found")]
|
||||||
|
MissingAuthProfile { auth_profile_id: String },
|
||||||
|
#[error("secret {secret_id} was not found")]
|
||||||
|
MissingSecret { secret_id: String },
|
||||||
|
#[error("secret {secret_id} does not have current version {version}")]
|
||||||
|
MissingSecretVersion { secret_id: String, version: u32 },
|
||||||
|
#[error("invalid secret payload for {secret_id}: {details}")]
|
||||||
|
InvalidAuthSecretValue { secret_id: String, details: String },
|
||||||
|
#[error("secret crypto error: {details}")]
|
||||||
|
SecretCrypto { details: String },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use crank_core::{ExecutionMode, Target, TransportBehavior};
|
|||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AdapterResponse, PreparedRequest, RuntimeError, RuntimeOperation, WindowExecutionResult,
|
AdapterResponse, PreparedRequest, ResolvedAuth, RuntimeError, RuntimeOperation,
|
||||||
|
WindowExecutionResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -42,8 +43,18 @@ impl RuntimeExecutor {
|
|||||||
&self,
|
&self,
|
||||||
operation: &RuntimeOperation,
|
operation: &RuntimeOperation,
|
||||||
input: &Value,
|
input: &Value,
|
||||||
|
) -> Result<Value, RuntimeError> {
|
||||||
|
self.execute_with_auth(operation, input, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_with_auth(
|
||||||
|
&self,
|
||||||
|
operation: &RuntimeOperation,
|
||||||
|
input: &Value,
|
||||||
|
resolved_auth: Option<&ResolvedAuth>,
|
||||||
) -> Result<Value, RuntimeError> {
|
) -> Result<Value, RuntimeError> {
|
||||||
let prepared_request = self.prepare_request(operation, input)?;
|
let prepared_request = self.prepare_request(operation, input)?;
|
||||||
|
let prepared_request = apply_resolved_auth(prepared_request, resolved_auth);
|
||||||
self.execute_prepared(operation, prepared_request).await
|
self.execute_prepared(operation, prepared_request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +62,15 @@ impl RuntimeExecutor {
|
|||||||
&self,
|
&self,
|
||||||
operation: &RuntimeOperation,
|
operation: &RuntimeOperation,
|
||||||
input: &Value,
|
input: &Value,
|
||||||
|
) -> Result<WindowExecutionResult, RuntimeError> {
|
||||||
|
self.execute_window_with_auth(operation, input, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_window_with_auth(
|
||||||
|
&self,
|
||||||
|
operation: &RuntimeOperation,
|
||||||
|
input: &Value,
|
||||||
|
resolved_auth: Option<&ResolvedAuth>,
|
||||||
) -> Result<WindowExecutionResult, RuntimeError> {
|
) -> Result<WindowExecutionResult, RuntimeError> {
|
||||||
let Some(streaming) = operation.execution_config.streaming.as_ref() else {
|
let Some(streaming) = operation.execution_config.streaming.as_ref() else {
|
||||||
return Err(RuntimeError::MissingStreamingConfig {
|
return Err(RuntimeError::MissingStreamingConfig {
|
||||||
@@ -66,6 +86,7 @@ impl RuntimeExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let prepared_request = self.prepare_request(operation, input)?;
|
let prepared_request = self.prepare_request(operation, input)?;
|
||||||
|
let prepared_request = apply_resolved_auth(prepared_request, resolved_auth);
|
||||||
let adapter_response = if matches!(
|
let adapter_response = if matches!(
|
||||||
streaming.transport_behavior,
|
streaming.transport_behavior,
|
||||||
TransportBehavior::ServerStream
|
TransportBehavior::ServerStream
|
||||||
@@ -86,6 +107,16 @@ impl RuntimeExecutor {
|
|||||||
&self,
|
&self,
|
||||||
operation: &RuntimeOperation,
|
operation: &RuntimeOperation,
|
||||||
input: &Value,
|
input: &Value,
|
||||||
|
) -> Result<WindowExecutionResult, RuntimeError> {
|
||||||
|
self.execute_session_seed_with_auth(operation, input, None)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_session_seed_with_auth(
|
||||||
|
&self,
|
||||||
|
operation: &RuntimeOperation,
|
||||||
|
input: &Value,
|
||||||
|
resolved_auth: Option<&ResolvedAuth>,
|
||||||
) -> Result<WindowExecutionResult, RuntimeError> {
|
) -> Result<WindowExecutionResult, RuntimeError> {
|
||||||
let Some(streaming) = operation.execution_config.streaming.as_ref() else {
|
let Some(streaming) = operation.execution_config.streaming.as_ref() else {
|
||||||
return Err(RuntimeError::MissingStreamingConfig {
|
return Err(RuntimeError::MissingStreamingConfig {
|
||||||
@@ -113,7 +144,8 @@ impl RuntimeExecutor {
|
|||||||
config.max_items = Some(seed_limit);
|
config.max_items = Some(seed_limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.execute_window(&seeded_operation, input).await
|
self.execute_window_with_auth(&seeded_operation, input, resolved_auth)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prepare_request(
|
pub fn prepare_request(
|
||||||
@@ -396,6 +428,17 @@ fn finalize_output(
|
|||||||
.unwrap_or_else(|| Value::Object(Map::new())))
|
.unwrap_or_else(|| Value::Object(Map::new())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_resolved_auth(
|
||||||
|
mut prepared_request: PreparedRequest,
|
||||||
|
resolved_auth: Option<&ResolvedAuth>,
|
||||||
|
) -> PreparedRequest {
|
||||||
|
if let Some(resolved_auth) = resolved_auth {
|
||||||
|
resolved_auth.apply(&mut prepared_request);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared_request
|
||||||
|
}
|
||||||
|
|
||||||
fn merge_headers(
|
fn merge_headers(
|
||||||
static_headers: &BTreeMap<String, String>,
|
static_headers: &BTreeMap<String, String>,
|
||||||
execution_headers: &BTreeMap<String, String>,
|
execution_headers: &BTreeMap<String, String>,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
mod aggregation;
|
mod aggregation;
|
||||||
|
mod auth;
|
||||||
mod error;
|
mod error;
|
||||||
mod executor;
|
mod executor;
|
||||||
mod model;
|
mod model;
|
||||||
mod redaction;
|
mod redaction;
|
||||||
|
mod secret_crypto;
|
||||||
mod streaming;
|
mod streaming;
|
||||||
|
|
||||||
|
pub use auth::ResolvedAuth;
|
||||||
pub use error::RuntimeError;
|
pub use error::RuntimeError;
|
||||||
pub use executor::RuntimeExecutor;
|
pub use executor::RuntimeExecutor;
|
||||||
pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation};
|
pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation};
|
||||||
|
pub use secret_crypto::SecretCrypto;
|
||||||
pub use streaming::WindowExecutionResult;
|
pub use streaming::WindowExecutionResult;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::error::ApiError;
|
use crate::RuntimeError;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SecretCrypto {
|
pub struct SecretCrypto {
|
||||||
@@ -22,15 +22,19 @@ struct CipherEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SecretCrypto {
|
impl SecretCrypto {
|
||||||
pub fn new(master_key: &str) -> Result<Self, ApiError> {
|
pub fn new(master_key: &str) -> Result<Self, RuntimeError> {
|
||||||
let trimmed = master_key.trim();
|
let trimmed = master_key.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Err(ApiError::internal("CRANK_MASTER_KEY must not be empty"));
|
return Err(RuntimeError::SecretCrypto {
|
||||||
|
details: "CRANK_MASTER_KEY must not be empty".to_owned(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let digest = Sha256::digest(trimmed.as_bytes());
|
let digest = Sha256::digest(trimmed.as_bytes());
|
||||||
let cipher = Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| {
|
let cipher = Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| {
|
||||||
ApiError::internal(format!("failed to initialize secret crypto: {error}"))
|
RuntimeError::SecretCrypto {
|
||||||
|
details: format!("failed to initialize secret crypto: {error}"),
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -43,9 +47,9 @@ impl SecretCrypto {
|
|||||||
&self.key_version
|
&self.key_version
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn encrypt(&self, value: &Value) -> Result<String, ApiError> {
|
pub fn encrypt(&self, value: &Value) -> Result<String, RuntimeError> {
|
||||||
let plaintext = serde_json::to_vec(value).map_err(|error| {
|
let plaintext = serde_json::to_vec(value).map_err(|error| RuntimeError::SecretCrypto {
|
||||||
ApiError::internal(format!("failed to serialize secret value: {error}"))
|
details: format!("failed to serialize secret value: {error}"),
|
||||||
})?;
|
})?;
|
||||||
let mut nonce_bytes = [0_u8; 12];
|
let mut nonce_bytes = [0_u8; 12];
|
||||||
OsRng.fill_bytes(&mut nonce_bytes);
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
@@ -53,39 +57,44 @@ impl SecretCrypto {
|
|||||||
let ciphertext = self
|
let ciphertext = self
|
||||||
.cipher
|
.cipher
|
||||||
.encrypt(nonce, plaintext.as_ref())
|
.encrypt(nonce, plaintext.as_ref())
|
||||||
.map_err(|error| {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
ApiError::internal(format!("failed to encrypt secret value: {error}"))
|
details: format!("failed to encrypt secret value: {error}"),
|
||||||
})?;
|
})?;
|
||||||
let envelope = CipherEnvelope {
|
let envelope = CipherEnvelope {
|
||||||
nonce_b64: STANDARD.encode(nonce_bytes),
|
nonce_b64: STANDARD.encode(nonce_bytes),
|
||||||
ciphertext_b64: STANDARD.encode(ciphertext),
|
ciphertext_b64: STANDARD.encode(ciphertext),
|
||||||
};
|
};
|
||||||
|
|
||||||
serde_json::to_string(&envelope).map_err(|error| {
|
serde_json::to_string(&envelope).map_err(|error| RuntimeError::SecretCrypto {
|
||||||
ApiError::internal(format!("failed to encode secret ciphertext: {error}"))
|
details: format!("failed to encode secret ciphertext: {error}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
pub fn decrypt(&self, ciphertext: &str) -> Result<Value, RuntimeError> {
|
||||||
pub fn decrypt(&self, ciphertext: &str) -> Result<Value, ApiError> {
|
let envelope: CipherEnvelope =
|
||||||
let envelope: CipherEnvelope = serde_json::from_str(ciphertext).map_err(|error| {
|
serde_json::from_str(ciphertext).map_err(|error| RuntimeError::SecretCrypto {
|
||||||
ApiError::internal(format!("failed to decode secret envelope: {error}"))
|
details: format!("failed to decode secret envelope: {error}"),
|
||||||
})?;
|
})?;
|
||||||
let nonce_bytes = STANDARD.decode(envelope.nonce_b64).map_err(|error| {
|
let nonce_bytes =
|
||||||
ApiError::internal(format!("failed to decode secret nonce: {error}"))
|
STANDARD
|
||||||
})?;
|
.decode(envelope.nonce_b64)
|
||||||
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
details: format!("failed to decode secret nonce: {error}"),
|
||||||
|
})?;
|
||||||
let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| {
|
let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| {
|
||||||
ApiError::internal(format!("failed to decode secret payload: {error}"))
|
RuntimeError::SecretCrypto {
|
||||||
|
details: format!("failed to decode secret payload: {error}"),
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
let plaintext = self
|
let plaintext = self
|
||||||
.cipher
|
.cipher
|
||||||
.decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref())
|
.decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref())
|
||||||
.map_err(|error| {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
ApiError::internal(format!("failed to decrypt secret value: {error}"))
|
details: format!("failed to decrypt secret value: {error}"),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
serde_json::from_slice(&plaintext).map_err(|error| {
|
serde_json::from_slice(&plaintext).map_err(|error| RuntimeError::SecretCrypto {
|
||||||
ApiError::internal(format!("failed to deserialize secret value: {error}"))
|
details: format!("failed to deserialize secret value: {error}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,12 +18,18 @@
|
|||||||
|
|
||||||
Что еще не работает end-to-end:
|
Что еще не работает end-to-end:
|
||||||
|
|
||||||
- runtime не резолвит `auth_profile_ref` при выполнении operation;
|
|
||||||
- `secret_ref` не подтягивает фактическое значение секрета;
|
- `secret_ref` не подтягивает фактическое значение секрета;
|
||||||
- wizard хранит auth как raw JSON headers;
|
- wizard хранит auth как raw JSON headers;
|
||||||
- `${secrets.*}` в UI является только текстовым placeholder;
|
- `${secrets.*}` в UI является только текстовым placeholder;
|
||||||
- отдельной страницы `Secrets` нет.
|
- отдельной страницы `Secrets` нет.
|
||||||
|
|
||||||
|
Что уже работает end-to-end:
|
||||||
|
|
||||||
|
- runtime резолвит `auth_profile_ref` при выполнении operation;
|
||||||
|
- `admin-api` test-runs используют тот же auth-aware execution path, что и `mcp-server`;
|
||||||
|
- `REST`, `GraphQL`, `gRPC`, `SOAP` и streaming execution paths получают bearer/basic/api-key auth до adapter layer;
|
||||||
|
- `last_used_at` у secret обновляется при успешном auth resolution.
|
||||||
|
|
||||||
## 3. Целевая модель
|
## 3. Целевая модель
|
||||||
|
|
||||||
### 3.1. `Secret`
|
### 3.1. `Secret`
|
||||||
@@ -238,8 +244,7 @@ DoD:
|
|||||||
|
|
||||||
1. `feat/secret-store-foundation`
|
1. `feat/secret-store-foundation`
|
||||||
2. `feat/auth-profile-secret-resolution`
|
2. `feat/auth-profile-secret-resolution`
|
||||||
3. `feat/runtime-upstream-auth`
|
3. `feat/secrets-ui`
|
||||||
4. `feat/secrets-ui`
|
4. `feat/wizard-auth-selector`
|
||||||
5. `feat/wizard-auth-selector`
|
|
||||||
|
|
||||||
Только после этого upstream auth можно считать реально реализованным.
|
Только после этого upstream auth можно считать реально реализованным.
|
||||||
|
|||||||
Reference in New Issue
Block a user