feat: complete Epic 1 production foundation
This commit is contained in:
@@ -37,10 +37,12 @@ pub struct RestAdapter {
|
||||
pub struct OutboundHttpPolicy {
|
||||
allowed_hosts: Vec<String>,
|
||||
denied_hosts: Vec<String>,
|
||||
max_request_bytes: usize,
|
||||
max_response_bytes: usize,
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
|
||||
const DEFAULT_MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
impl Default for RestAdapter {
|
||||
fn default() -> Self {
|
||||
@@ -152,12 +154,19 @@ impl RestAdapter {
|
||||
.timeout(Duration::from_millis(request.timeout_ms));
|
||||
|
||||
if let Some(body) = &request.body {
|
||||
enforce_request_body_limit(body, self.policy.max_request_bytes)?;
|
||||
builder = builder.json(body);
|
||||
}
|
||||
|
||||
if let Some(context) = trusted_context {
|
||||
context.mark_dispatch_started();
|
||||
}
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
let headers = normalize_headers(response.headers());
|
||||
if status.is_redirection() {
|
||||
return Err(RestAdapterError::RedirectNotAllowed);
|
||||
}
|
||||
let body = decode_body(response, self.policy.max_response_bytes).await?;
|
||||
|
||||
if !status.is_success() {
|
||||
@@ -195,10 +204,13 @@ fn upstream_outcome(error: &RestAdapterError) -> UpstreamOutcome {
|
||||
UpstreamOutcome::ServerError
|
||||
}
|
||||
RestAdapterError::UnexpectedStatus { .. } => UpstreamOutcome::UnexpectedStatus,
|
||||
RestAdapterError::Transport(error) if error.is_timeout() => UpstreamOutcome::Timeout,
|
||||
RestAdapterError::Transport(_) => UpstreamOutcome::TransportError,
|
||||
RestAdapterError::Transport { timeout: true, .. } => UpstreamOutcome::Timeout,
|
||||
RestAdapterError::Transport { .. } => UpstreamOutcome::TransportError,
|
||||
RestAdapterError::ResponseTooLarge { .. } => UpstreamOutcome::ResponseTooLarge,
|
||||
RestAdapterError::TargetNotAllowed { .. } => UpstreamOutcome::Rejected,
|
||||
RestAdapterError::RequestTooLarge { .. } => UpstreamOutcome::InvalidRequest,
|
||||
RestAdapterError::TargetNotAllowed { .. } | RestAdapterError::RedirectNotAllowed => {
|
||||
UpstreamOutcome::Rejected
|
||||
}
|
||||
RestAdapterError::WindowExpired => UpstreamOutcome::WindowExpired,
|
||||
RestAdapterError::InvalidSseEvent => UpstreamOutcome::InvalidResponse,
|
||||
RestAdapterError::InvalidBaseUrl { .. }
|
||||
@@ -215,6 +227,7 @@ impl Default for OutboundHttpPolicy {
|
||||
Self {
|
||||
allowed_hosts: Vec::new(),
|
||||
denied_hosts: Vec::new(),
|
||||
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
|
||||
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
|
||||
}
|
||||
}
|
||||
@@ -226,6 +239,25 @@ impl OutboundHttpPolicy {
|
||||
denied_hosts: Vec<String>,
|
||||
max_response_bytes: usize,
|
||||
) -> Result<Self, RestAdapterError> {
|
||||
Self::try_new_with_limits(
|
||||
allowed_hosts,
|
||||
denied_hosts,
|
||||
DEFAULT_MAX_REQUEST_BYTES,
|
||||
max_response_bytes,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn try_new_with_limits(
|
||||
allowed_hosts: Vec<String>,
|
||||
denied_hosts: Vec<String>,
|
||||
max_request_bytes: usize,
|
||||
max_response_bytes: usize,
|
||||
) -> Result<Self, RestAdapterError> {
|
||||
if max_request_bytes == 0 {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: "outbound request limit must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
if max_response_bytes == 0 {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: "outbound response limit must be greater than zero".to_owned(),
|
||||
@@ -234,6 +266,7 @@ impl OutboundHttpPolicy {
|
||||
Ok(Self {
|
||||
allowed_hosts: validate_host_patterns(allowed_hosts)?,
|
||||
denied_hosts: validate_host_patterns(denied_hosts)?,
|
||||
max_request_bytes,
|
||||
max_response_bytes,
|
||||
})
|
||||
}
|
||||
@@ -250,9 +283,14 @@ impl OutboundHttpPolicy {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_request_bytes(mut self, max_request_bytes: usize) -> Self {
|
||||
self.max_request_bytes = max_request_bytes;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn validate_base_url(&self, base_url: &str) -> Result<(), RestAdapterError> {
|
||||
let url = reqwest::Url::parse(base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
|
||||
url: base_url.to_owned(),
|
||||
url: "url".to_owned(),
|
||||
})?;
|
||||
self.validate_url(&url)
|
||||
}
|
||||
@@ -263,18 +301,18 @@ impl OutboundHttpPolicy {
|
||||
|| url.password().is_some()
|
||||
{
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: url.to_string(),
|
||||
target: "url".to_owned(),
|
||||
});
|
||||
}
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| RestAdapterError::TargetNotAllowed {
|
||||
target: url.to_string(),
|
||||
target: "host".to_owned(),
|
||||
})?;
|
||||
self.validate_host(host)?;
|
||||
if !self.is_explicitly_allowed(host) && is_local_hostname(host) {
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: host.to_owned(),
|
||||
target: "host".to_owned(),
|
||||
});
|
||||
}
|
||||
if let Ok(address) = host.parse::<IpAddr>()
|
||||
@@ -282,7 +320,7 @@ impl OutboundHttpPolicy {
|
||||
&& !is_public_ip(address)
|
||||
{
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: host.to_owned(),
|
||||
target: "ip".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -290,16 +328,29 @@ impl OutboundHttpPolicy {
|
||||
|
||||
fn validate_host(&self, host: &str) -> Result<(), RestAdapterError> {
|
||||
let host = normalize_host(host);
|
||||
let denied = self
|
||||
.denied_hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, &host));
|
||||
if denied {
|
||||
return Err(RestAdapterError::TargetNotAllowed { target: host });
|
||||
if self.is_denied_host(&host) {
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: "host".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_denied_host(&self, host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
self.denied_hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, &host))
|
||||
}
|
||||
|
||||
fn is_denied_ip(&self, address: IpAddr) -> bool {
|
||||
ip_match_hosts(address).iter().any(|host| {
|
||||
self.denied_hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, host))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_explicitly_allowed(&self, host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
self.allowed_hosts
|
||||
@@ -326,12 +377,15 @@ impl Resolve for PolicyDnsResolver {
|
||||
.await
|
||||
.map_err(|error| Box::new(error) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||
let addresses = resolved
|
||||
.filter(|address| explicitly_allowed || is_public_ip(address.ip()))
|
||||
.filter(|address| {
|
||||
!policy.is_denied_ip(address.ip())
|
||||
&& (explicitly_allowed || is_public_ip(address.ip()))
|
||||
})
|
||||
.collect::<Vec<SocketAddr>>();
|
||||
if addresses.is_empty() {
|
||||
return Err(boxed_io_error(format!(
|
||||
"outbound target {host} did not resolve to an allowed address"
|
||||
)));
|
||||
return Err(boxed_io_error(
|
||||
"outbound target did not resolve to an allowed address".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(Box::new(addresses.into_iter()) as Addrs)
|
||||
})
|
||||
@@ -396,6 +450,26 @@ fn is_public_ip(address: IpAddr) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn ip_match_hosts(address: IpAddr) -> Vec<String> {
|
||||
match address {
|
||||
IpAddr::V4(address) => vec![address.to_string()],
|
||||
IpAddr::V6(address) => {
|
||||
let mut hosts = vec![address.to_string()];
|
||||
if let Some(mapped) = address.to_ipv4_mapped() {
|
||||
hosts.push(mapped.to_string());
|
||||
} else {
|
||||
let segments = address.segments();
|
||||
if segments[..6].iter().all(|segment| *segment == 0) {
|
||||
let [a, b] = segments[6].to_be_bytes();
|
||||
let [c, d] = segments[7].to_be_bytes();
|
||||
hosts.push(Ipv4Addr::new(a, b, c, d).to_string());
|
||||
}
|
||||
}
|
||||
hosts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_public_ipv4(address: Ipv4Addr) -> bool {
|
||||
let octets = address.octets();
|
||||
!(address.is_private()
|
||||
@@ -439,12 +513,12 @@ fn is_public_ipv6(address: Ipv6Addr) -> bool {
|
||||
fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> {
|
||||
let base_url =
|
||||
reqwest::Url::parse(&target.base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
|
||||
url: target.base_url.clone(),
|
||||
url: "url".to_owned(),
|
||||
})?;
|
||||
let path = substitute_path_params(&target.path_template, &request.path_params);
|
||||
let mut url = base_url.join(path.trim_start_matches('/')).map_err(|_| {
|
||||
RestAdapterError::InvalidBaseUrl {
|
||||
url: target.base_url.clone(),
|
||||
url: "url".to_owned(),
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -475,24 +549,49 @@ fn build_headers(
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
for (name, value) in &target.static_headers {
|
||||
insert_header(&mut headers, name, value)?;
|
||||
insert_header(&mut headers, name, value, HeaderSource::StaticTarget)?;
|
||||
}
|
||||
|
||||
for (name, value) in &request.headers {
|
||||
insert_header(&mut headers, name, value)?;
|
||||
let trusted = request
|
||||
.trusted_header_names
|
||||
.iter()
|
||||
.any(|trusted| trusted.eq_ignore_ascii_case(name));
|
||||
insert_header(
|
||||
&mut headers,
|
||||
name,
|
||||
value,
|
||||
HeaderSource::PreparedRequest { trusted },
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(), RestAdapterError> {
|
||||
#[derive(Clone, Copy)]
|
||||
enum HeaderSource {
|
||||
StaticTarget,
|
||||
PreparedRequest { trusted: bool },
|
||||
}
|
||||
|
||||
fn insert_header(
|
||||
headers: &mut HeaderMap,
|
||||
name: &str,
|
||||
value: &str,
|
||||
source: HeaderSource,
|
||||
) -> Result<(), RestAdapterError> {
|
||||
let header_name =
|
||||
HeaderName::try_from(name).map_err(|_| RestAdapterError::InvalidHeaderName {
|
||||
header: name.to_owned(),
|
||||
})?;
|
||||
if is_reserved_correlation_header(&header_name) {
|
||||
if is_ignored_reserved_header(&header_name) {
|
||||
return Ok(());
|
||||
}
|
||||
if is_forbidden_reserved_header(&header_name, source) {
|
||||
return Err(RestAdapterError::InvalidHeaderName {
|
||||
header: header_name.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
let header_value =
|
||||
HeaderValue::try_from(value).map_err(|_| RestAdapterError::InvalidHeaderValue {
|
||||
header: name.to_owned(),
|
||||
@@ -502,7 +601,7 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_reserved_correlation_header(name: &HeaderName) -> bool {
|
||||
fn is_ignored_reserved_header(name: &HeaderName) -> bool {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"traceparent"
|
||||
@@ -514,6 +613,22 @@ fn is_reserved_correlation_header(name: &HeaderName) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_forbidden_reserved_header(name: &HeaderName, source: HeaderSource) -> bool {
|
||||
let framing = matches!(
|
||||
name.as_str(),
|
||||
"host" | "content-length" | "transfer-encoding" | "connection" | "upgrade"
|
||||
);
|
||||
let credential_or_safety = matches!(
|
||||
name.as_str(),
|
||||
"authorization" | "cookie" | "idempotency-key"
|
||||
);
|
||||
let untrusted_credential_or_safety = match source {
|
||||
HeaderSource::StaticTarget => credential_or_safety,
|
||||
HeaderSource::PreparedRequest { trusted } => credential_or_safety && !trusted,
|
||||
};
|
||||
framing || untrusted_credential_or_safety
|
||||
}
|
||||
|
||||
fn set_span_parent_from_traceparent(span: &Span, traceparent: &str) -> bool {
|
||||
let mut parts = traceparent.split('-');
|
||||
let (Some("00"), Some(trace_id), Some(parent_id), Some(flags), None) = (
|
||||
@@ -612,6 +727,59 @@ async fn decode_body(
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_request_body_limit(
|
||||
body: &Value,
|
||||
max_request_bytes: usize,
|
||||
) -> Result<(), RestAdapterError> {
|
||||
if max_request_bytes == 0 {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: "outbound request limit must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
let mut writer = LimitWriter::new(max_request_bytes);
|
||||
let result = serde_json::to_writer(&mut writer, body);
|
||||
if writer.exceeded {
|
||||
return Err(RestAdapterError::RequestTooLarge {
|
||||
limit_bytes: max_request_bytes,
|
||||
});
|
||||
}
|
||||
result.map_err(|_| RestAdapterError::InvalidHeaderValue {
|
||||
header: "body".to_owned(),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct LimitWriter {
|
||||
written: usize,
|
||||
limit: usize,
|
||||
exceeded: bool,
|
||||
}
|
||||
|
||||
impl LimitWriter {
|
||||
fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
written: 0,
|
||||
limit,
|
||||
exceeded: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Write for LimitWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
if self.written.saturating_add(buf.len()) > self.limit {
|
||||
self.exceeded = true;
|
||||
return Err(io::Error::other("request body exceeds configured limit"));
|
||||
}
|
||||
self.written += buf.len();
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
|
||||
headers
|
||||
.iter()
|
||||
@@ -633,3 +801,38 @@ fn to_reqwest_method(method: HttpMethod) -> reqwest::Method {
|
||||
HttpMethod::Delete => reqwest::Method::DELETE,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{OutboundHttpPolicy, ip_match_hosts};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn denied_resolved_ip_overrides_explicitly_allowed_hostname() {
|
||||
let policy = OutboundHttpPolicy::try_new_with_limits(
|
||||
vec!["api.example.test".to_owned()],
|
||||
vec!["169.254.169.254".to_owned()],
|
||||
4 * 1024 * 1024,
|
||||
4 * 1024 * 1024,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.is_explicitly_allowed("api.example.test"));
|
||||
assert!(policy.is_denied_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_resolved_ip_checks_ipv4_mapped_ipv6_alias() {
|
||||
let policy = OutboundHttpPolicy::try_new_with_limits(
|
||||
vec!["api.example.test".to_owned()],
|
||||
vec!["127.0.0.1".to_owned()],
|
||||
4 * 1024 * 1024,
|
||||
4 * 1024 * 1024,
|
||||
)
|
||||
.unwrap();
|
||||
let mapped = IpAddr::V6(Ipv6Addr::from(0xffff_7f00_0001u128));
|
||||
|
||||
assert!(ip_match_hosts(mapped).contains(&"127.0.0.1".to_owned()));
|
||||
assert!(policy.is_denied_ip(mapped));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,18 @@ pub enum RestAdapterError {
|
||||
InvalidHeaderName { header: String },
|
||||
#[error("invalid header value for {header}")]
|
||||
InvalidHeaderValue { header: String },
|
||||
#[error("outbound target is not allowed: {target}")]
|
||||
#[error("outbound target is not allowed")]
|
||||
TargetNotAllowed { target: String },
|
||||
#[error("outbound redirects are not allowed")]
|
||||
RedirectNotAllowed,
|
||||
#[error("rest request exceeds the configured limit of {limit_bytes} bytes")]
|
||||
RequestTooLarge { limit_bytes: usize },
|
||||
#[error("rest response exceeds the configured limit of {limit_bytes} bytes")]
|
||||
ResponseTooLarge { limit_bytes: usize },
|
||||
#[error("invalid outbound HTTP configuration: {details}")]
|
||||
InvalidConfiguration { details: String },
|
||||
#[error("request failed")]
|
||||
Transport(#[from] reqwest::Error),
|
||||
Transport { timeout: bool, connect: bool },
|
||||
#[error("sse collection window expired before stream completed")]
|
||||
WindowExpired,
|
||||
#[error("rest endpoint returned status {status}")]
|
||||
@@ -28,3 +32,12 @@ pub enum RestAdapterError {
|
||||
#[error("sse stream produced malformed event payload")]
|
||||
InvalidSseEvent,
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for RestAdapterError {
|
||||
fn from(value: reqwest::Error) -> Self {
|
||||
Self::Transport {
|
||||
timeout: value.is_timeout(),
|
||||
connect: value.is_connect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ mod model;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crank_core::{
|
||||
AdapterResponse, ExecutionMode, PreparedRequest, Protocol, ProtocolAdapter,
|
||||
AdapterResponse, DispatchEvidence, ExecutionMode, PreparedRequest, Protocol, ProtocolAdapter,
|
||||
ProtocolAdapterError, RestTarget, RuntimeRequestContext, Target,
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ impl ProtocolAdapter for RestAdapter {
|
||||
path_params: prepared.path_params.clone(),
|
||||
query_params: prepared.query_params.clone(),
|
||||
headers: prepared.headers.clone(),
|
||||
trusted_header_names: prepared.trusted_header_names.clone(),
|
||||
body: prepared.body.clone(),
|
||||
timeout_ms: prepared.timeout_ms,
|
||||
};
|
||||
@@ -55,6 +56,43 @@ fn rest_target(target: &Target) -> Result<&RestTarget, ProtocolAdapterError> {
|
||||
|
||||
impl From<RestAdapterError> for ProtocolAdapterError {
|
||||
fn from(value: RestAdapterError) -> Self {
|
||||
ProtocolAdapterError::Message(value.to_string())
|
||||
match value {
|
||||
RestAdapterError::InvalidBaseUrl { .. }
|
||||
| RestAdapterError::InvalidConfiguration { .. } => Self::InvalidConfiguration,
|
||||
RestAdapterError::InvalidPathParameter { .. }
|
||||
| RestAdapterError::InvalidQueryParameter { .. }
|
||||
| RestAdapterError::InvalidHeaderName { .. }
|
||||
| RestAdapterError::InvalidHeaderValue { .. } => Self::InvalidPreparedRequest,
|
||||
RestAdapterError::RedirectNotAllowed => Self::TargetRejected,
|
||||
RestAdapterError::TargetNotAllowed { .. } => Self::TargetRejected,
|
||||
RestAdapterError::RequestTooLarge { .. } => Self::RequestTooLarge,
|
||||
RestAdapterError::ResponseTooLarge { .. } => Self::ResponseTooLarge {
|
||||
dispatch: DispatchEvidence::MayHaveDispatched,
|
||||
},
|
||||
RestAdapterError::Transport { timeout, connect } if timeout => Self::Timeout {
|
||||
dispatch: if connect {
|
||||
DispatchEvidence::NotDispatched
|
||||
} else {
|
||||
DispatchEvidence::MayHaveDispatched
|
||||
},
|
||||
},
|
||||
RestAdapterError::Transport { connect, .. } => Self::Transport {
|
||||
dispatch: if connect {
|
||||
DispatchEvidence::NotDispatched
|
||||
} else {
|
||||
DispatchEvidence::MayHaveDispatched
|
||||
},
|
||||
},
|
||||
RestAdapterError::WindowExpired => Self::Timeout {
|
||||
dispatch: DispatchEvidence::MayHaveDispatched,
|
||||
},
|
||||
RestAdapterError::UnexpectedStatus { status, .. } => Self::UnexpectedStatus {
|
||||
status,
|
||||
dispatch: DispatchEvidence::MayHaveDispatched,
|
||||
},
|
||||
RestAdapterError::InvalidSseEvent => Self::InvalidResponse {
|
||||
dispatch: DispatchEvidence::MayHaveDispatched,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -11,6 +11,8 @@ pub struct RestRequest {
|
||||
pub query_params: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub trusted_header_names: BTreeSet<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<Value>,
|
||||
pub timeout_ms: u64,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod integration {
|
||||
mod client;
|
||||
mod outbound_security;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
@@ -9,7 +9,8 @@ use axum::{
|
||||
};
|
||||
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
|
||||
use crank_core::{
|
||||
HttpMethod, PreparedRequest, ProtocolAdapter, RestTarget, RuntimeRequestContext, Target,
|
||||
DispatchEvidence, HttpMethod, PreparedRequest, ProtocolAdapter, ProtocolAdapterError,
|
||||
RestTarget, RuntimeRequestContext, Target,
|
||||
};
|
||||
use opentelemetry::{
|
||||
global,
|
||||
@@ -38,6 +39,7 @@ async fn executes_rest_request_and_normalizes_json_response() {
|
||||
headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let response = adapter.execute(&target, &request).await.unwrap();
|
||||
@@ -55,6 +57,77 @@ async fn executes_rest_request_and_normalizes_json_response() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_auth_profile_header_is_allowed_after_runtime_resolution() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = test_adapter();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/users/{user_id}".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let request = RestRequest {
|
||||
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::from([(
|
||||
"authorization".to_owned(),
|
||||
"Bearer resolved-auth-profile-token".to_owned(),
|
||||
)]),
|
||||
trusted_header_names: BTreeSet::from(["authorization".to_owned()]),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
};
|
||||
|
||||
let response = adapter.execute(&target, &request).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response.body["authorization"],
|
||||
"Bearer resolved-auth-profile-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn untrusted_prepared_credential_header_is_rejected_before_dispatch() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = test_adapter();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/users/{user_id}".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let request = RestRequest {
|
||||
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::from([(
|
||||
"authorization".to_owned(),
|
||||
"Bearer user-mapped-secret".to_owned(),
|
||||
)]),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let error = adapter.execute(&target, &request).await.unwrap_err();
|
||||
|
||||
assert!(matches!(error, RestAdapterError::InvalidHeaderName { .. }));
|
||||
assert!(!format!("{error:?} {error}").contains("user-mapped-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_error_debug_is_redacted() {
|
||||
let error = RestAdapterError::Transport {
|
||||
timeout: false,
|
||||
connect: true,
|
||||
};
|
||||
let rendered = format!("{error:?} {error}");
|
||||
|
||||
assert!(!rendered.contains("http://"));
|
||||
assert!(!rendered.contains("story19-transport-canary"));
|
||||
assert!(rendered.contains("Transport"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protocol_context_overrides_mapped_correlation_headers() {
|
||||
let base_url = spawn_test_server().await;
|
||||
@@ -134,6 +207,7 @@ async fn current_trace_context_overrides_mapped_traceparent() {
|
||||
)]),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let response = test_adapter()
|
||||
@@ -171,6 +245,7 @@ async fn user_configured_propagation_headers_are_removed_without_trusted_context
|
||||
headers: BTreeMap::new(),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let response = test_adapter().execute(&target, &request).await.unwrap();
|
||||
@@ -196,17 +271,54 @@ async fn returns_unexpected_status_with_normalized_body() {
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let error = adapter.execute(&target, &request).await.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
&error,
|
||||
RestAdapterError::UnexpectedStatus {
|
||||
status: 502,
|
||||
body: Value::Object(_)
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
ProtocolAdapterError::from(error),
|
||||
ProtocolAdapterError::UnexpectedStatus {
|
||||
status: 502,
|
||||
dispatch: DispatchEvidence::MayHaveDispatched,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_failure_is_known_not_dispatched() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
let target = RestTarget {
|
||||
base_url: format!("http://{address}"),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/write".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let request = RestRequest {
|
||||
path_params: BTreeMap::new(),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Some(json!({"value": 1})),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let error = test_adapter().execute(&target, &request).await.unwrap_err();
|
||||
assert!(matches!(
|
||||
ProtocolAdapterError::from(error),
|
||||
ProtocolAdapterError::Transport {
|
||||
dispatch: DispatchEvidence::NotDispatched,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -244,10 +356,7 @@ async fn does_not_follow_redirects() {
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RestAdapterError::UnexpectedStatus { status: 303, .. }
|
||||
));
|
||||
assert!(matches!(error, RestAdapterError::RedirectNotAllowed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -281,6 +390,7 @@ fn empty_request() -> RestRequest {
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +444,9 @@ async fn create_user(
|
||||
.get("tracestate")
|
||||
.and_then(|value| value.to_str().ok());
|
||||
let baggage = headers.get("baggage").and_then(|value| value.to_str().ok());
|
||||
let authorization = headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok());
|
||||
|
||||
let mut response = json!({
|
||||
"id": user_id,
|
||||
@@ -370,6 +483,12 @@ async fn create_user(
|
||||
if let Some(baggage) = baggage {
|
||||
response.insert("baggage".to_owned(), Value::String(baggage.to_owned()));
|
||||
}
|
||||
if let Some(authorization) = authorization {
|
||||
response.insert(
|
||||
"authorization".to_owned(),
|
||||
Value::String(authorization.to_owned()),
|
||||
);
|
||||
}
|
||||
|
||||
Json(Value::Object(response.clone()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
net::SocketAddr,
|
||||
process::Command,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
routing::{any, post},
|
||||
};
|
||||
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
|
||||
use crank_core::{HttpMethod, ProtocolAdapterError, RestTarget};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[tokio::test]
|
||||
async fn forbidden_credential_and_framing_headers_fail_before_bytes_leave() {
|
||||
let observed_requests = Arc::new(AtomicUsize::new(0));
|
||||
let base_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
|
||||
let adapter = RestAdapter::with_policy(OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]));
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/capture".to_owned(),
|
||||
static_headers: BTreeMap::from([
|
||||
(
|
||||
"authorization".to_owned(),
|
||||
"Bearer story19-static-secret".to_owned(),
|
||||
),
|
||||
("host".to_owned(), "metadata.internal".to_owned()),
|
||||
]),
|
||||
};
|
||||
|
||||
let error = adapter
|
||||
.execute(&target, &json_request(json!({"value": "must-not-leave"})))
|
||||
.await
|
||||
.expect_err("forbidden headers must reject before dispatch");
|
||||
|
||||
assert!(matches!(error, RestAdapterError::InvalidHeaderName { .. }));
|
||||
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
|
||||
let rendered = format!("{error:?} {error}");
|
||||
assert!(!rendered.contains("story19-static-secret"));
|
||||
assert!(!rendered.contains("metadata.internal"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_body_limit_fails_before_bytes_leave() {
|
||||
let observed_requests = Arc::new(AtomicUsize::new(0));
|
||||
let base_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
|
||||
let adapter = RestAdapter::with_policy(
|
||||
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]).with_max_request_bytes(16),
|
||||
);
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/capture".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let error = adapter
|
||||
.execute(
|
||||
&target,
|
||||
&json_request(json!({"payload": "story19-body-canary-that-exceeds-limit"})),
|
||||
)
|
||||
.await
|
||||
.expect_err("oversized request body must fail before dispatch");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RestAdapterError::RequestTooLarge { limit_bytes: 16 }
|
||||
));
|
||||
let rendered = format!("{error:?} {error}");
|
||||
assert_eq!(
|
||||
ProtocolAdapterError::from(error),
|
||||
ProtocolAdapterError::RequestTooLarge
|
||||
);
|
||||
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
|
||||
assert!(!rendered.contains("story19-body-canary"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_environment_is_ignored_by_default() {
|
||||
const CHILD_ENV: &str = "CRANK_OUTBOUND_PROXY_CHILD";
|
||||
const TARGET_ENV: &str = "CRANK_OUTBOUND_PROXY_TARGET";
|
||||
|
||||
if std::env::var_os(CHILD_ENV).is_some() {
|
||||
let target = std::env::var(TARGET_ENV).expect("target url passed by parent");
|
||||
let adapter = RestAdapter::default();
|
||||
let request = json_request(json!({"payload": "proxy-env-canary"}));
|
||||
let rest_target = RestTarget {
|
||||
base_url: target,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/capture".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let _ = adapter.execute(&rest_target, &request).await.expect_err(
|
||||
"unresolvable target should fail locally instead of being sent through proxy env",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let observed_requests = Arc::new(AtomicUsize::new(0));
|
||||
let proxy_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
|
||||
let current_exe = std::env::current_exe().expect("current test binary");
|
||||
let status = Command::new(current_exe)
|
||||
.arg("--exact")
|
||||
.arg("integration::outbound_security::proxy_environment_is_ignored_by_default")
|
||||
.arg("--nocapture")
|
||||
.env(CHILD_ENV, "1")
|
||||
.env(TARGET_ENV, "http://public.example.test/capture")
|
||||
.env("HTTP_PROXY", &proxy_url)
|
||||
.env("HTTPS_PROXY", &proxy_url)
|
||||
.env("ALL_PROXY", &proxy_url)
|
||||
.status()
|
||||
.expect("spawn child proxy regression");
|
||||
|
||||
assert!(status.success());
|
||||
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_target_diagnostic_is_redacted() {
|
||||
let error = OutboundHttpPolicy::default()
|
||||
.validate_base_url("http://user:story19-url-secret@127.0.0.1:8080/private")
|
||||
.expect_err("userinfo and private target must be rejected");
|
||||
|
||||
let rendered = format!("{error:?} {error}");
|
||||
assert!(!rendered.contains("story19-url-secret"));
|
||||
assert!(!rendered.contains("127.0.0.1"));
|
||||
assert!(!rendered.contains("/private"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_rule_wins_over_explicit_allow() {
|
||||
let policy = OutboundHttpPolicy::try_new(
|
||||
vec!["api.example.test".to_owned()],
|
||||
vec!["api.example.test".to_owned()],
|
||||
4 * 1024 * 1024,
|
||||
)
|
||||
.expect("valid policy");
|
||||
|
||||
let error = policy
|
||||
.validate_base_url("https://api.example.test/users")
|
||||
.expect_err("deny must override allow");
|
||||
|
||||
assert!(matches!(error, RestAdapterError::TargetNotAllowed { .. }));
|
||||
}
|
||||
|
||||
fn json_request(body: Value) -> RestRequest {
|
||||
RestRequest {
|
||||
path_params: BTreeMap::new(),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Some(body),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_counting_server(observed_requests: Arc<AtomicUsize>) -> String {
|
||||
let app = Router::new()
|
||||
.route("/capture", post(capture))
|
||||
.fallback(any(capture_any))
|
||||
.with_state(observed_requests);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
async fn capture(State(observed_requests): State<Arc<AtomicUsize>>) -> Json<Value> {
|
||||
observed_requests.fetch_add(1, Ordering::SeqCst);
|
||||
Json(json!({"ok": true}))
|
||||
}
|
||||
|
||||
async fn capture_any(State(observed_requests): State<Arc<AtomicUsize>>) -> StatusCode {
|
||||
observed_requests.fetch_add(1, Ordering::SeqCst);
|
||||
StatusCode::OK
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
use argon2::{Algorithm, Params, Version};
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
};
|
||||
use rand::RngExt;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HashPasswordError {
|
||||
@@ -14,7 +15,10 @@ pub enum HashPasswordError {
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str, pepper: &str) -> Result<String, HashPasswordError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let mut salt_bytes = [0u8; 16];
|
||||
rand::rng().fill(&mut salt_bytes);
|
||||
let salt = SaltString::encode_b64(&salt_bytes)
|
||||
.map_err(|error| HashPasswordError::Hash(error.to_string()))?;
|
||||
let password = format!("{password}{pepper}");
|
||||
password_hasher()?
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
|
||||
@@ -6,5 +6,6 @@ pub use hashing::{HashPasswordError, hash_password, verify_password};
|
||||
pub use password_provider::PasswordIdentityProvider;
|
||||
pub use session_cookie::{
|
||||
SESSION_COOKIE_NAME, SessionCookie, SessionCookieError, cleared_session_cookie,
|
||||
create_session_cookie, extract_session_token, hash_session_secret, session_cookie,
|
||||
create_csrf_token, create_session_cookie, extract_session_token, hash_csrf_token,
|
||||
hash_session_secret, session_cookie,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ use crank_core::{
|
||||
use crank_registry::PostgresRegistry;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::hashing::verify_password;
|
||||
use crate::hashing::{hash_password, verify_password};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PasswordIdentityProvider {
|
||||
@@ -36,15 +36,23 @@ impl IdentityProvider for PasswordIdentityProvider {
|
||||
&self,
|
||||
payload: crank_core::LoginPayload,
|
||||
) -> Result<LoginOutcome, IdentityError> {
|
||||
let user = self
|
||||
let Some(user) = self
|
||||
.registry
|
||||
.get_auth_user_by_email(&payload.email)
|
||||
.await
|
||||
.map_err(|error| IdentityError::Internal(error.to_string()))?
|
||||
.ok_or(IdentityError::BadCredentials)?;
|
||||
else {
|
||||
let _ = hash_password(&payload.password, &self.password_pepper);
|
||||
return Err(IdentityError::BadCredentials);
|
||||
};
|
||||
|
||||
if user.user.status != crank_core::UserStatus::Active {
|
||||
return Err(IdentityError::AccountDisabled);
|
||||
let _ = verify_password(
|
||||
&payload.password,
|
||||
&self.password_pepper,
|
||||
&user.password_hash,
|
||||
);
|
||||
return Err(IdentityError::BadCredentials);
|
||||
}
|
||||
|
||||
if !verify_password(
|
||||
|
||||
@@ -40,11 +40,36 @@ pub fn hash_session_secret(
|
||||
session_id: &UserSessionId,
|
||||
session_value: &str,
|
||||
session_secret: &str,
|
||||
) -> String {
|
||||
hash_scoped_secret("session", session_id, session_value, session_secret)
|
||||
}
|
||||
|
||||
pub fn create_csrf_token() -> String {
|
||||
let mut secret_bytes = [0_u8; 32];
|
||||
rand::rng().fill(&mut secret_bytes);
|
||||
URL_SAFE_NO_PAD.encode(secret_bytes)
|
||||
}
|
||||
|
||||
pub fn hash_csrf_token(
|
||||
session_id: &UserSessionId,
|
||||
csrf_token: &str,
|
||||
session_secret: &str,
|
||||
) -> String {
|
||||
hash_scoped_secret("csrf", session_id, csrf_token, session_secret)
|
||||
}
|
||||
|
||||
fn hash_scoped_secret(
|
||||
scope: &str,
|
||||
session_id: &UserSessionId,
|
||||
secret_value: &str,
|
||||
session_secret: &str,
|
||||
) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(scope.as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(session_id.as_str().as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(session_value.as_bytes());
|
||||
digest.update(secret_value.as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(session_secret.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest.finalize())
|
||||
@@ -83,8 +108,8 @@ mod tests {
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
use super::{
|
||||
SESSION_COOKIE_NAME, cleared_session_cookie, create_session_cookie, extract_session_token,
|
||||
session_cookie,
|
||||
SESSION_COOKIE_NAME, cleared_session_cookie, create_csrf_token, create_session_cookie,
|
||||
extract_session_token, hash_csrf_token, hash_session_secret, session_cookie,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -113,4 +138,15 @@ mod tests {
|
||||
assert!(session.session_id.as_str().starts_with("sess_"));
|
||||
assert!(session.value.contains('.'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_hash_is_scoped_from_session_hash() {
|
||||
let session = create_session_cookie(24).unwrap();
|
||||
let csrf = create_csrf_token();
|
||||
assert_eq!(csrf.len(), 43);
|
||||
assert_ne!(
|
||||
hash_csrf_token(&session.session_id, &csrf, "secret"),
|
||||
hash_session_secret(&session.session_id, &csrf, "secret")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
|
||||
http::{
|
||||
HeaderMap, StatusCode,
|
||||
header::{AUTHORIZATION, ORIGIN},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -43,11 +46,14 @@ pub(super) async fn require_machine_access(
|
||||
headers: &HeaderMap,
|
||||
required_scope: PlatformApiKeyScope,
|
||||
) -> Result<VerifiedMachineCredential, MachineAccessError> {
|
||||
let secret =
|
||||
bearer_token(headers).ok_or(MachineAccessError::Denied(StatusCode::UNAUTHORIZED))?;
|
||||
let secret = bearer_token(headers).ok_or_else(|| {
|
||||
record_machine_access_denial("missing_or_ambiguous_bearer", None);
|
||||
MachineAccessError::Denied(StatusCode::UNAUTHORIZED)
|
||||
})?;
|
||||
let credential = resolve_machine_credential(state, path, secret).await?;
|
||||
|
||||
if !allows_scope(&credential.scopes, required_scope) {
|
||||
record_machine_access_denial("scope", credential.platform_api_key_id.as_ref());
|
||||
return Err(MachineAccessError::Denied(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
@@ -60,7 +66,10 @@ pub(super) async fn require_approval_access(
|
||||
headers: &HeaderMap,
|
||||
required_scope: PlatformApiKeyScope,
|
||||
) -> Result<crank_registry::PlatformApiKeyRecord, StatusCode> {
|
||||
let secret = bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
let secret = bearer_token(headers).ok_or_else(|| {
|
||||
record_unknown_approval_access_denial("missing_or_ambiguous_bearer");
|
||||
StatusCode::UNAUTHORIZED
|
||||
})?;
|
||||
let secret_hash = hash_access_secret(secret);
|
||||
let Some(api_key) = observe_db_query(
|
||||
DbOperation::MachineAccessRead,
|
||||
@@ -75,10 +84,16 @@ pub(super) async fn require_approval_access(
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
else {
|
||||
record_unknown_approval_access_denial("unknown_or_inactive");
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
};
|
||||
|
||||
if !approval_allows_scope(&api_key.api_key.scopes, required_scope) {
|
||||
record_approval_access_denial(&api_key, "scope");
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
if !approval_allows_origin(&api_key.api_key.allowed_origins, headers) {
|
||||
record_approval_access_denial(&api_key, "origin");
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
@@ -92,13 +107,20 @@ pub(super) async fn require_approval_access(
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(|error| match error {
|
||||
crank_registry::RegistryError::PlatformApiKeyInactive { .. } => StatusCode::UNAUTHORIZED,
|
||||
_ => 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 mut values = headers.get_all(AUTHORIZATION).iter();
|
||||
let value = values.next()?.to_str().ok()?;
|
||||
if values.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let (scheme, token) = value.split_once(' ')?;
|
||||
if !scheme.eq_ignore_ascii_case("Bearer") || token.is_empty() {
|
||||
return None;
|
||||
@@ -114,18 +136,6 @@ pub(super) fn credential_allows_security_level(
|
||||
security_level_rank(credential.max_security_level) >= security_level_rank(required_level)
|
||||
}
|
||||
|
||||
pub(super) fn serialize_security_level(level: OperationSecurityLevel) -> &'static str {
|
||||
match level {
|
||||
OperationSecurityLevel::Standard => "standard",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn serialize_machine_access_mode(mode: crank_core::MachineAccessMode) -> &'static str {
|
||||
match mode {
|
||||
crank_core::MachineAccessMode::StaticAgentKey => "static_agent_key",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn hash_access_secret(secret: &str) -> String {
|
||||
let digest = Sha256::digest(secret.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest)
|
||||
@@ -145,7 +155,10 @@ async fn resolve_machine_credential(
|
||||
.verify_bearer_token(&path.workspace_slug, &path.agent_slug, token)
|
||||
.await
|
||||
.map_err(|_| MachineAccessError::Unavailable)?
|
||||
.ok_or(MachineAccessError::Denied(StatusCode::UNAUTHORIZED))
|
||||
.ok_or_else(|| {
|
||||
record_machine_access_denial("unknown_or_inactive", None);
|
||||
MachineAccessError::Denied(StatusCode::UNAUTHORIZED)
|
||||
})
|
||||
}
|
||||
|
||||
async fn verify_static_agent_key(
|
||||
@@ -164,7 +177,13 @@ async fn verify_static_agent_key(
|
||||
)
|
||||
.instrument(read_span.clone())
|
||||
.await
|
||||
.map_err(|_| MachineAccessError::Unavailable);
|
||||
.map_err(|error| match error {
|
||||
crank_registry::RegistryError::PlatformApiKeyInactive { .. } => {
|
||||
record_machine_access_denial("unknown_or_inactive", None);
|
||||
MachineAccessError::Denied(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
_ => MachineAccessError::Unavailable,
|
||||
});
|
||||
let api_key = match api_key_result {
|
||||
Ok(api_key) => {
|
||||
StageOutcome::Success.record(&read_span);
|
||||
@@ -189,7 +208,13 @@ async fn verify_static_agent_key(
|
||||
.touch_platform_api_key(&api_key.api_key.workspace_id, &api_key.api_key.id, &used_at)
|
||||
.instrument(touch_span.clone())
|
||||
.await
|
||||
.map_err(|_| MachineAccessError::Unavailable);
|
||||
.map_err(|error| match error {
|
||||
crank_registry::RegistryError::PlatformApiKeyInactive { .. } => {
|
||||
record_machine_access_denial("unknown_or_inactive", None);
|
||||
MachineAccessError::Denied(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
_ => MachineAccessError::Unavailable,
|
||||
});
|
||||
match touch_result {
|
||||
Ok(()) => {
|
||||
StageOutcome::Success.record(&touch_span);
|
||||
@@ -207,9 +232,46 @@ async fn verify_static_agent_key(
|
||||
machine_access_mode: crank_core::MachineAccessMode::StaticAgentKey,
|
||||
max_security_level: crank_core::OperationSecurityLevel::Standard,
|
||||
scopes: api_key.api_key.scopes,
|
||||
platform_api_key_id: Some(api_key.api_key.id),
|
||||
}))
|
||||
}
|
||||
|
||||
fn record_machine_access_denial(
|
||||
reason: &'static str,
|
||||
platform_api_key_id: Option<&crank_core::PlatformApiKeyId>,
|
||||
) {
|
||||
let (request_id, trace_id) = crank_observability::current_request_correlation();
|
||||
let credential_ref = platform_api_key_id
|
||||
.map(crank_core::PlatformApiKeyId::as_str)
|
||||
.unwrap_or("unknown");
|
||||
tracing::warn!(
|
||||
target: "crank::audit",
|
||||
action = "credential.platform_api_key.access_denied",
|
||||
credential_type = "platform_api_key.mcp_client",
|
||||
credential_ref = credential_ref,
|
||||
outcome = "denied",
|
||||
reason = reason,
|
||||
request_id = request_id.as_deref().unwrap_or(""),
|
||||
trace_id = trace_id.as_deref().unwrap_or(""),
|
||||
"mcp platform key access denied"
|
||||
);
|
||||
}
|
||||
|
||||
fn record_unknown_approval_access_denial(reason: &'static str) {
|
||||
let (request_id, trace_id) = crank_observability::current_request_correlation();
|
||||
tracing::warn!(
|
||||
target: "crank::audit",
|
||||
action = "credential.platform_api_key.access_denied",
|
||||
credential_type = "platform_api_key.approval",
|
||||
credential_ref = "unknown",
|
||||
outcome = "denied",
|
||||
reason = reason,
|
||||
request_id = request_id.as_deref().unwrap_or(""),
|
||||
trace_id = trace_id.as_deref().unwrap_or(""),
|
||||
"approval platform key access denied"
|
||||
);
|
||||
}
|
||||
|
||||
fn allows_scope(scopes: &[PlatformApiKeyScope], required_scope: PlatformApiKeyScope) -> bool {
|
||||
match required_scope {
|
||||
PlatformApiKeyScope::Read => scopes.iter().any(|scope| {
|
||||
@@ -249,8 +311,286 @@ fn approval_allows_scope(
|
||||
})
|
||||
}
|
||||
|
||||
fn approval_allows_origin(allowed_origins: &[String], headers: &HeaderMap) -> bool {
|
||||
if allowed_origins.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut origins = headers.get_all(ORIGIN).iter();
|
||||
let Some(origin) = origins.next() else {
|
||||
return true;
|
||||
};
|
||||
if origins.next().is_some() {
|
||||
return false;
|
||||
};
|
||||
let Ok(origin) = origin.to_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
allowed_origins
|
||||
.iter()
|
||||
.any(|allowed_origin| allowed_origin == origin)
|
||||
}
|
||||
|
||||
fn record_approval_access_denial(
|
||||
api_key: &crank_registry::PlatformApiKeyRecord,
|
||||
reason: &'static str,
|
||||
) {
|
||||
let (request_id, trace_id) = crank_observability::current_request_correlation();
|
||||
let agent_id = api_key
|
||||
.api_key
|
||||
.agent_id
|
||||
.as_ref()
|
||||
.map(|id| id.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
tracing::warn!(
|
||||
target: "crank::audit",
|
||||
action = "credential.platform_api_key.access_denied",
|
||||
credential_type = "platform_api_key.approval",
|
||||
credential_ref = %api_key.api_key.id,
|
||||
workspace_id = %api_key.api_key.workspace_id,
|
||||
agent_id = agent_id,
|
||||
outcome = "denied",
|
||||
reason = reason,
|
||||
request_id = request_id.as_deref().unwrap_or(""),
|
||||
trace_id = trace_id.as_deref().unwrap_or(""),
|
||||
"approval platform key access denied"
|
||||
);
|
||||
}
|
||||
|
||||
fn security_level_rank(level: OperationSecurityLevel) -> u8 {
|
||||
match level {
|
||||
OperationSecurityLevel::Standard => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use axum::http::{
|
||||
HeaderMap, HeaderValue,
|
||||
header::{AUTHORIZATION, ORIGIN},
|
||||
};
|
||||
use crank_core::{
|
||||
AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
|
||||
PlatformApiKeyStatus, WorkspaceId,
|
||||
};
|
||||
use crank_registry::PlatformApiKeyRecord;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{
|
||||
Event, Id, Metadata, Subscriber,
|
||||
field::{Field, Visit},
|
||||
span::{Attributes, Record},
|
||||
};
|
||||
|
||||
use super::{
|
||||
approval_allows_origin, bearer_token, record_approval_access_denial,
|
||||
record_machine_access_denial,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn approval_origin_policy_allows_non_browser_clients_without_origin() {
|
||||
let allowed_origins = vec!["https://allowed.example.test".to_owned()];
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert!(approval_allows_origin(&allowed_origins, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_origin_policy_rejects_ambiguous_or_malformed_origin() {
|
||||
let allowed_origins = vec!["https://allowed.example.test".to_owned()];
|
||||
|
||||
let mut duplicate = HeaderMap::new();
|
||||
duplicate.append(
|
||||
ORIGIN,
|
||||
HeaderValue::from_static("https://allowed.example.test"),
|
||||
);
|
||||
duplicate.append(
|
||||
ORIGIN,
|
||||
HeaderValue::from_static("https://evil.example.test"),
|
||||
);
|
||||
assert!(!approval_allows_origin(&allowed_origins, &duplicate));
|
||||
|
||||
let mut malformed = HeaderMap::new();
|
||||
malformed.insert(ORIGIN, HeaderValue::from_bytes(b"\xff").unwrap());
|
||||
assert!(!approval_allows_origin(&allowed_origins, &malformed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_token_rejects_duplicate_authorization_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(AUTHORIZATION, HeaderValue::from_static("Bearer first"));
|
||||
headers.append(AUTHORIZATION, HeaderValue::from_static("Bearer second"));
|
||||
|
||||
assert_eq!(bearer_token(&headers), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn machine_access_denial_audit_uses_known_key_id_only_when_resolved() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let subscriber = CapturingSubscriber {
|
||||
events: Arc::clone(&events),
|
||||
};
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let key_id = PlatformApiKeyId::new("pk_mcp_scope_audit");
|
||||
|
||||
crank_observability::with_request_correlation(
|
||||
"req_mcp_scope_audit".to_owned(),
|
||||
"0af7651916cd43dd8448eb211c80319c".to_owned(),
|
||||
async {
|
||||
record_machine_access_denial("scope", Some(&key_id));
|
||||
record_machine_access_denial("unknown_or_inactive", None);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let events = events.lock().unwrap();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(
|
||||
events[0].get("credential_ref").map(String::as_str),
|
||||
Some("pk_mcp_scope_audit")
|
||||
);
|
||||
assert_eq!(events[0].get("reason").map(String::as_str), Some("scope"));
|
||||
assert_eq!(
|
||||
events[1].get("credential_ref").map(String::as_str),
|
||||
Some("unknown")
|
||||
);
|
||||
assert_eq!(
|
||||
events[1].get("reason").map(String::as_str),
|
||||
Some("unknown_or_inactive")
|
||||
);
|
||||
for event in events.iter() {
|
||||
assert_eq!(
|
||||
event.get("credential_type").map(String::as_str),
|
||||
Some("platform_api_key.mcp_client")
|
||||
);
|
||||
assert_eq!(event.get("outcome").map(String::as_str), Some("denied"));
|
||||
assert_eq!(
|
||||
event.get("request_id").map(String::as_str),
|
||||
Some("req_mcp_scope_audit")
|
||||
);
|
||||
assert_eq!(
|
||||
event.get("trace_id").map(String::as_str),
|
||||
Some("0af7651916cd43dd8448eb211c80319c")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_approval_access_denial_emits_bounded_audit_event() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let subscriber = CapturingSubscriber {
|
||||
events: Arc::clone(&events),
|
||||
};
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let record = PlatformApiKeyRecord {
|
||||
api_key: PlatformApiKey {
|
||||
id: PlatformApiKeyId::new("pk_approval_audit"),
|
||||
workspace_id: WorkspaceId::new("ws_audit"),
|
||||
agent_id: Some(AgentId::new("agent_audit")),
|
||||
key_kind: PlatformApiKeyKind::Approval,
|
||||
name: "approval-audit".to_owned(),
|
||||
prefix: "crk_appr_audit".to_owned(),
|
||||
scopes: vec![PlatformApiKeyScope::ReadPending],
|
||||
status: PlatformApiKeyStatus::Active,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
allowed_origins: vec!["https://allowed.example.test".to_owned()],
|
||||
},
|
||||
};
|
||||
|
||||
crank_observability::with_request_correlation(
|
||||
"req_approval_audit".to_owned(),
|
||||
"0af7651916cd43dd8448eb211c80319c".to_owned(),
|
||||
async {
|
||||
record_approval_access_denial(&record, "origin");
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let events = events.lock().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
let event = &events[0];
|
||||
assert_eq!(
|
||||
event.get("action").map(String::as_str),
|
||||
Some("credential.platform_api_key.access_denied")
|
||||
);
|
||||
assert_eq!(
|
||||
event.get("credential_type").map(String::as_str),
|
||||
Some("platform_api_key.approval")
|
||||
);
|
||||
assert_eq!(
|
||||
event.get("credential_ref").map(String::as_str),
|
||||
Some("pk_approval_audit")
|
||||
);
|
||||
assert_eq!(event.get("outcome").map(String::as_str), Some("denied"));
|
||||
assert_eq!(event.get("reason").map(String::as_str), Some("origin"));
|
||||
assert_eq!(
|
||||
event.get("request_id").map(String::as_str),
|
||||
Some("req_approval_audit")
|
||||
);
|
||||
assert_eq!(
|
||||
event.get("trace_id").map(String::as_str),
|
||||
Some("0af7651916cd43dd8448eb211c80319c")
|
||||
);
|
||||
let serialized = format!("{event:?}");
|
||||
assert!(!serialized.contains("secret"));
|
||||
assert!(!serialized.contains("allowed.example.test"));
|
||||
}
|
||||
|
||||
struct CapturingSubscriber {
|
||||
events: Arc<Mutex<Vec<BTreeMap<String, String>>>>,
|
||||
}
|
||||
|
||||
impl Subscriber for CapturingSubscriber {
|
||||
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
|
||||
metadata.target() == "crank::audit"
|
||||
}
|
||||
|
||||
fn new_span(&self, _span: &Attributes<'_>) -> Id {
|
||||
Id::from_u64(1)
|
||||
}
|
||||
|
||||
fn record(&self, _span: &Id, _values: &Record<'_>) {}
|
||||
|
||||
fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
|
||||
|
||||
fn event(&self, event: &Event<'_>) {
|
||||
if event.metadata().target() != "crank::audit" {
|
||||
return;
|
||||
}
|
||||
let mut visitor = FieldCaptureVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
self.events.lock().unwrap().push(visitor.fields);
|
||||
}
|
||||
|
||||
fn enter(&self, _span: &Id) {}
|
||||
|
||||
fn exit(&self, _span: &Id) {}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FieldCaptureVisitor {
|
||||
fields: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Visit for FieldCaptureVisitor {
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
self.fields
|
||||
.insert(field.name().to_owned(), value.to_owned());
|
||||
}
|
||||
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
self.fields
|
||||
.insert(field.name().to_owned(), format!("{value:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,32 +13,29 @@ use axum::{
|
||||
routing::{get, post},
|
||||
};
|
||||
use crank_core::{
|
||||
ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore,
|
||||
CorrelationContext, InvocationLevel, InvocationSource, InvocationStatus, OperationApprovalMode,
|
||||
PlatformApiKeyScope, SecretId,
|
||||
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore,
|
||||
CorrelationContext, ExecutionOrigin, InvocationLevel, InvocationSource, InvocationStatus,
|
||||
PlatformApiKeyScope, SecretId, SecretStatus,
|
||||
};
|
||||
use crank_registry::{
|
||||
CreateApprovalRequest, DecideApprovalRequest, ExpireApprovalRequest, PostgresRegistry,
|
||||
ApprovalRequestRecord, DecideApprovalRequest, ExpireApprovalRequest, PostgresRegistry,
|
||||
PublishedAgentTool,
|
||||
};
|
||||
use crank_runtime::{
|
||||
RequestRateLimiter, ResolvedAuth, RuntimeError, RuntimeExecutionRequest, RuntimeExecutor,
|
||||
RuntimeOperation, RuntimeRequestContext, SecretCrypto,
|
||||
ExecutionAuthorization, RequestRateLimiter, ResolvedAuth, RuntimeError,
|
||||
RuntimeExecutionRequest, RuntimeExecutor, RuntimeRequestContext, SecretCrypto,
|
||||
};
|
||||
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
|
||||
use futures_util::stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::{Instrument, info};
|
||||
use tracing::{Instrument, info, warn};
|
||||
|
||||
use crate::{
|
||||
access::{
|
||||
credential_allows_security_level, serialize_machine_access_mode, serialize_security_level,
|
||||
},
|
||||
access::credential_allows_security_level,
|
||||
approval_execution::{execute_approved_request, spawn_approval_recovery},
|
||||
approval_response::approval_required_response,
|
||||
auth::{SharedMachineCredentialVerifier, VerifiedMachineCredential},
|
||||
catalog::PublishedToolCatalog,
|
||||
jsonrpc::{
|
||||
@@ -49,10 +46,7 @@ use crate::{
|
||||
rate_limit::{rate_limited_jsonrpc_response, rate_limited_status_response},
|
||||
request_context::{RequestContext, apply_request_context},
|
||||
session::{ActiveSessionMetrics, SessionState, SharedSessionStore, spawn_session_cleanup},
|
||||
tool_error::{
|
||||
ToolErrorContract, generic_tool_error_contract, runtime_error_code,
|
||||
tool_error_contract_from_runtime, tool_error_text, tool_error_value,
|
||||
},
|
||||
tool_error::{execution_locale, tool_error_contract_from_failure},
|
||||
tool_search::handle_catalog_tool_call,
|
||||
transport::{
|
||||
AllowedOrigins, ResponseMode, json_response, negotiate_post_response_mode,
|
||||
@@ -61,17 +55,30 @@ use crate::{
|
||||
with_request_id_header,
|
||||
},
|
||||
};
|
||||
mod approval_policy;
|
||||
mod invocation_history;
|
||||
mod metrics;
|
||||
mod request;
|
||||
mod response;
|
||||
mod stages;
|
||||
mod tool_resolution;
|
||||
use self::approval_policy::{
|
||||
ApprovalPolicyContext, ApprovalPolicyResult, maybe_handle_approval_policy,
|
||||
};
|
||||
use self::metrics::{ActiveStreamGuard, McpRequestMetrics};
|
||||
use self::request::{
|
||||
ApprovalDecisionPayload, InitializeParams, ToolCallParams, ToolsListParams, paginate_tools_list,
|
||||
};
|
||||
pub(super) use self::response::{
|
||||
success_tool_response, take_confirmation_token, tool_error_response,
|
||||
};
|
||||
use self::stages::{
|
||||
enforce_traced_rate_limit, require_traced_approval_access, require_traced_machine_access,
|
||||
};
|
||||
pub(super) use self::tool_resolution::{resolve_generated_tool, runtime_operation};
|
||||
#[cfg(test)]
|
||||
use invocation_history::observe_invocation_history_outcome;
|
||||
use invocation_history::persist_invocation_for_key;
|
||||
pub(super) use invocation_history::{InvocationRecord, persist_invocation};
|
||||
|
||||
const TRANSPORT_SESSION_TTL_MS: u64 = 86_400_000;
|
||||
@@ -92,28 +99,6 @@ pub(super) struct AppState {
|
||||
allowed_origins: AllowedOrigins,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct InitializeParams {
|
||||
#[serde(rename = "protocolVersion")]
|
||||
protocol_version: String,
|
||||
#[serde(default)]
|
||||
capabilities: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ToolCallParams {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
arguments: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApprovalDecisionPayload {
|
||||
approve: String,
|
||||
#[serde(default)]
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct ResolvedToolCall {
|
||||
tool: PublishedAgentTool,
|
||||
@@ -123,6 +108,7 @@ struct ToolCallExecution {
|
||||
tool: PublishedAgentTool,
|
||||
arguments: Value,
|
||||
confirmation_token: Option<String>,
|
||||
platform_api_key_id: Option<crank_core::PlatformApiKeyId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -346,7 +332,10 @@ async fn list_pending_approvals(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(items) => Json(json!({ "items": items })).into_response(),
|
||||
Ok(items) => Json(json!({
|
||||
"items": items.into_iter().map(safe_approval_record).collect::<Vec<_>>()
|
||||
}))
|
||||
.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -461,6 +450,21 @@ async fn decide_approval_request(
|
||||
};
|
||||
let approval_id = ApprovalRequestId::new(path.approval_id);
|
||||
|
||||
let expected_record = match observe_db_query(
|
||||
DbOperation::ApprovalRead,
|
||||
state.registry.get_approval_request_for_agent(
|
||||
&key.api_key.workspace_id,
|
||||
agent_id,
|
||||
&approval_id,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
};
|
||||
|
||||
match observe_db_query(
|
||||
DbOperation::ApprovalWrite,
|
||||
state
|
||||
@@ -469,9 +473,12 @@ async fn decide_approval_request(
|
||||
workspace_id: &key.api_key.workspace_id,
|
||||
agent_id,
|
||||
approval_id: &approval_id,
|
||||
operation_id: &expected_record.approval.operation_id,
|
||||
operation_version: expected_record.approval.operation_version,
|
||||
request_payload: &expected_record.approval.request_payload,
|
||||
status,
|
||||
decided_at: OffsetDateTime::now_utc(),
|
||||
decided_by_key_id: &key.api_key.id,
|
||||
decided_by_key_id: Some(&key.api_key.id),
|
||||
response_payload: Some(json!({ "approve": payload.approve })),
|
||||
decision_note: payload.note.as_deref(),
|
||||
}),
|
||||
@@ -502,11 +509,11 @@ async fn decide_approval_request(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(record) => Json(json!(record)).into_response(),
|
||||
Ok(record) => Json(json!(safe_approval_record(record))).into_response(),
|
||||
Err(response) => response,
|
||||
}
|
||||
}
|
||||
Ok(Some(record)) => Json(json!(record)).into_response(),
|
||||
Ok(Some(record)) => Json(json!(safe_approval_record(record))).into_response(),
|
||||
Ok(None) => {
|
||||
terminal_decision_response(&state, &key.api_key.workspace_id, agent_id, &approval_id)
|
||||
.await
|
||||
@@ -535,7 +542,7 @@ async fn approval_record_response(
|
||||
{
|
||||
expire_approval_response(state, workspace_id, agent_id, approval_id).await
|
||||
}
|
||||
Ok(Some(record)) => Json(json!(record)).into_response(),
|
||||
Ok(Some(record)) => Json(json!(safe_approval_record(record))).into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
@@ -570,7 +577,7 @@ async fn terminal_decision_response(
|
||||
| ApprovalRequestStatus::Expired
|
||||
) =>
|
||||
{
|
||||
Json(json!(record)).into_response()
|
||||
Json(json!(safe_approval_record(record))).into_response()
|
||||
}
|
||||
Ok(Some(_)) => StatusCode::CONFLICT.into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
@@ -578,6 +585,17 @@ async fn terminal_decision_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_approval_record(mut record: ApprovalRequestRecord) -> ApprovalRequestRecord {
|
||||
record.approval.request_payload =
|
||||
crank_core::sanitize_invocation_preview(&record.approval.request_payload);
|
||||
record.approval.response_payload = record
|
||||
.approval
|
||||
.response_payload
|
||||
.as_ref()
|
||||
.map(crank_core::sanitize_invocation_preview);
|
||||
record
|
||||
}
|
||||
|
||||
async fn expire_approval_response(
|
||||
state: &Arc<AppState>,
|
||||
workspace_id: &crank_core::WorkspaceId,
|
||||
@@ -597,7 +615,7 @@ async fn expire_approval_response(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(record)) => Json(json!(record)).into_response(),
|
||||
Ok(Some(record)) => Json(json!(safe_approval_record(record))).into_response(),
|
||||
Ok(None) => StatusCode::CONFLICT.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
@@ -843,10 +861,39 @@ async fn mcp_post_response(
|
||||
{
|
||||
Ok(catalog) => {
|
||||
let definitions = catalog_tool_definitions(&catalog);
|
||||
let list_params: ToolsListParams = match serde_json::from_value(params(message))
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return transport_response(
|
||||
StatusCode::OK,
|
||||
jsonrpc_error(
|
||||
request_id(message),
|
||||
-32602,
|
||||
"invalid tools/list parameters",
|
||||
),
|
||||
response_mode,
|
||||
None,
|
||||
Some(&session.protocol_version),
|
||||
);
|
||||
}
|
||||
};
|
||||
let page = match paginate_tools_list(definitions, list_params) {
|
||||
Ok(page) => page,
|
||||
Err(message_text) => {
|
||||
return transport_response(
|
||||
StatusCode::OK,
|
||||
jsonrpc_error(request_id(message), -32602, message_text),
|
||||
response_mode,
|
||||
None,
|
||||
Some(&session.protocol_version),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
transport_response(
|
||||
StatusCode::OK,
|
||||
jsonrpc_result(request_id(message), json!({ "tools": definitions })),
|
||||
jsonrpc_result(request_id(message), page),
|
||||
response_mode,
|
||||
None,
|
||||
Some(&session.protocol_version),
|
||||
@@ -940,22 +987,38 @@ pub(super) async fn handle_tool_call(
|
||||
) -> Response {
|
||||
let transport_request_id = transport_correlation.request_id().as_str();
|
||||
if !credential_allows_security_level(credential, resolved.tool.operation.security_level) {
|
||||
let failure = crank_core::ExecutionFailure::new(
|
||||
crank_core::ExecutionErrorCode::AuthorizationDenied,
|
||||
transport_correlation.clone(),
|
||||
);
|
||||
persist_invocation_for_key(
|
||||
&state,
|
||||
&resolved.tool,
|
||||
credential.platform_api_key_id.as_ref(),
|
||||
InvocationRecord {
|
||||
request_id: Some(transport_request_id),
|
||||
trace_id: Some(transport_correlation.trace_id().as_str()),
|
||||
tool_name: &resolved.tool.tool_name,
|
||||
status: InvocationStatus::Error,
|
||||
level: InvocationLevel::Warn,
|
||||
message: failure.error_code().as_str(),
|
||||
status_code: None,
|
||||
error_kind: Some(failure.error_code().as_str()),
|
||||
execution_stage: Some(failure.stage()),
|
||||
execution_error_code: Some(failure.error_code()),
|
||||
retryability: Some(failure.retryability()),
|
||||
outcome_certainty: Some(failure.outcome_certainty()),
|
||||
duration: Duration::ZERO,
|
||||
request_preview: crank_core::sanitize_invocation_preview(&arguments),
|
||||
response_preview: Value::Null,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return tool_error_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
generic_tool_error_contract(
|
||||
"machine_access_insufficient",
|
||||
format!(
|
||||
"machine access mode {} does not satisfy {} operation security",
|
||||
serialize_machine_access_mode(credential.machine_access_mode),
|
||||
serialize_security_level(resolved.tool.operation.security_level),
|
||||
),
|
||||
transport_request_id,
|
||||
transport_correlation.trace_id().as_str(),
|
||||
false,
|
||||
Some("Используйте ключ агента с достаточным уровнем доступа."),
|
||||
),
|
||||
tool_error_contract_from_failure(&failure, execution_locale(message)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -968,6 +1031,7 @@ pub(super) async fn handle_tool_call(
|
||||
tool: resolved.tool,
|
||||
arguments,
|
||||
confirmation_token,
|
||||
platform_api_key_id: credential.platform_api_key_id.clone(),
|
||||
},
|
||||
transport_correlation,
|
||||
)
|
||||
@@ -1016,10 +1080,7 @@ async fn resolve_runtime_auth_for_task(
|
||||
registry.get_auth_profile(workspace_id, auth_profile_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "load auth profile",
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?
|
||||
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
||||
auth_profile_id: auth_profile_id.as_str().to_owned(),
|
||||
})?;
|
||||
@@ -1044,39 +1105,47 @@ async fn resolve_auth_profile(
|
||||
registry.get_secret(workspace_id, secret_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "load secret",
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?
|
||||
.ok_or_else(|| RuntimeError::MissingSecret {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
})?;
|
||||
if secret.secret.status != SecretStatus::Active {
|
||||
return Err(RuntimeError::InvalidAuthSecretValue {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
reason: "secret is not active".to_owned(),
|
||||
});
|
||||
}
|
||||
let version = observe_db_query(
|
||||
DbOperation::SecretRead,
|
||||
registry.get_current_secret_version(workspace_id, secret_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "load current secret version",
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?
|
||||
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
version: secret.secret.current_version,
|
||||
})?;
|
||||
let plaintext = secret_crypto.decrypt(
|
||||
let plaintext = secret_crypto.decrypt_for_epoch(
|
||||
&version.secret_version.key_version,
|
||||
version.master_key_epoch,
|
||||
&version.secret_version.ciphertext,
|
||||
)?;
|
||||
observe_db_query(
|
||||
if observe_db_query(
|
||||
DbOperation::SecretTouch,
|
||||
registry.touch_secret(workspace_id, secret_id, &used_at),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "touch secret",
|
||||
details: error.to_string(),
|
||||
})?;
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
target: "crank::audit",
|
||||
action = "credential.secret.touch_failed",
|
||||
credential_type = "secret",
|
||||
credential_ref = %secret_id.as_str(),
|
||||
outcome = "metadata_touch_failed",
|
||||
"secret last-used metadata was not updated"
|
||||
);
|
||||
}
|
||||
secrets.insert(SecretId::new(secret_id.as_str()), plaintext);
|
||||
}
|
||||
|
||||
@@ -1092,6 +1161,7 @@ async fn handle_base_tool_call(
|
||||
transport_correlation: &CorrelationContext,
|
||||
) -> Response {
|
||||
let transport_request_id = transport_correlation.request_id().as_str();
|
||||
let platform_api_key_id = execution.platform_api_key_id;
|
||||
let tool = execution.tool;
|
||||
let arguments = execution.arguments;
|
||||
let operation = runtime_operation(&tool);
|
||||
@@ -1103,15 +1173,16 @@ async fn handle_base_tool_call(
|
||||
.is_some_and(|policy| policy.required)
|
||||
{
|
||||
let approval_span = Stage::ApprovalCheck.span();
|
||||
let response = maybe_handle_approval_policy(
|
||||
&state,
|
||||
let response = maybe_handle_approval_policy(ApprovalPolicyContext {
|
||||
state: &state,
|
||||
session,
|
||||
message,
|
||||
response_mode,
|
||||
&tool,
|
||||
&arguments,
|
||||
tool: &tool,
|
||||
arguments: &arguments,
|
||||
platform_api_key_id: platform_api_key_id.as_ref(),
|
||||
transport_correlation,
|
||||
)
|
||||
})
|
||||
.instrument(approval_span.clone())
|
||||
.await;
|
||||
if let Some(result) = response {
|
||||
@@ -1144,30 +1215,45 @@ async fn handle_base_tool_call(
|
||||
if let Some(token) = execution.confirmation_token {
|
||||
runtime_request_context = runtime_request_context.with_confirmation_token(token);
|
||||
}
|
||||
let request_preview = build_request_preview(&state.runtime, &operation, &arguments);
|
||||
let started_at = Instant::now();
|
||||
let resolved_auth =
|
||||
resolve_operation_auth(&state, &tool.workspace_id, &operation.execution_config).await;
|
||||
|
||||
let result = match resolved_auth {
|
||||
Ok(resolved_auth) => {
|
||||
state
|
||||
.runtime
|
||||
.execute_request(
|
||||
RuntimeExecutionRequest::new(&operation, &arguments)
|
||||
.with_optional_auth(resolved_auth.as_ref())
|
||||
.with_context(&runtime_request_context),
|
||||
)
|
||||
.await
|
||||
match RuntimeExecutionRequest::try_new(
|
||||
&tool.workspace_id,
|
||||
ExecutionOrigin::AgentSnapshot,
|
||||
Some(&tool.agent_id),
|
||||
&operation,
|
||||
&arguments,
|
||||
ExecutionAuthorization::Authorized,
|
||||
resolved_auth.as_ref(),
|
||||
&runtime_request_context,
|
||||
Instant::now()
|
||||
+ Duration::from_millis(operation.execution_config.timeout_ms.max(1)),
|
||||
) {
|
||||
Ok(request) => state.runtime.execute_outcome(request).await,
|
||||
Err(_) => Err(crank_core::ExecutionFailure::new(
|
||||
crank_core::ExecutionErrorCode::RuntimeInternal,
|
||||
transport_correlation.clone(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
Err(error) => Err(crank_runtime::normalize_runtime_error(
|
||||
&error,
|
||||
transport_correlation,
|
||||
)),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(output) => {
|
||||
persist_invocation(
|
||||
Ok(success) => {
|
||||
let request_preview = success.request_preview;
|
||||
let output = success.output;
|
||||
persist_invocation_for_key(
|
||||
&state,
|
||||
&tool,
|
||||
platform_api_key_id.as_ref(),
|
||||
InvocationRecord {
|
||||
request_id: Some(transport_request_id),
|
||||
trace_id: Some(transport_correlation.trace_id().as_str()),
|
||||
@@ -1177,6 +1263,10 @@ async fn handle_base_tool_call(
|
||||
message: "agent tool call completed",
|
||||
status_code: None,
|
||||
error_kind: None,
|
||||
execution_stage: Some(crank_core::ExecutionStage::Runtime),
|
||||
execution_error_code: None,
|
||||
retryability: Some(crank_core::Retryability::Never),
|
||||
outcome_certainty: Some(crank_core::OutcomeCertainty::Certain),
|
||||
duration: started_at.elapsed(),
|
||||
request_preview,
|
||||
response_preview: output.clone(),
|
||||
@@ -1186,21 +1276,26 @@ async fn handle_base_tool_call(
|
||||
|
||||
success_tool_response(message, response_mode, &session.protocol_version, output)
|
||||
}
|
||||
Err(error) => {
|
||||
persist_invocation(
|
||||
Err(failure) => {
|
||||
persist_invocation_for_key(
|
||||
&state,
|
||||
&tool,
|
||||
platform_api_key_id.as_ref(),
|
||||
InvocationRecord {
|
||||
request_id: Some(transport_request_id),
|
||||
trace_id: Some(transport_correlation.trace_id().as_str()),
|
||||
tool_name: &tool.tool_name,
|
||||
status: InvocationStatus::Error,
|
||||
level: InvocationLevel::Error,
|
||||
message: runtime_error_code(&error),
|
||||
status_code: None,
|
||||
error_kind: Some(runtime_error_code(&error)),
|
||||
message: failure.error_code().as_str(),
|
||||
status_code: failure.upstream_status(),
|
||||
error_kind: Some(failure.error_code().as_str()),
|
||||
execution_stage: Some(failure.stage()),
|
||||
execution_error_code: Some(failure.error_code()),
|
||||
retryability: Some(failure.retryability()),
|
||||
outcome_certainty: Some(failure.outcome_certainty()),
|
||||
duration: started_at.elapsed(),
|
||||
request_preview,
|
||||
request_preview: Value::Null,
|
||||
response_preview: Value::Null,
|
||||
},
|
||||
)
|
||||
@@ -1210,188 +1305,12 @@ async fn handle_base_tool_call(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
tool_error_contract_from_runtime(
|
||||
&error,
|
||||
transport_request_id,
|
||||
transport_correlation.trace_id().as_str(),
|
||||
),
|
||||
tool_error_contract_from_failure(&failure, execution_locale(message)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalPolicyResult {
|
||||
Required(Response),
|
||||
Error(Response),
|
||||
}
|
||||
|
||||
async fn maybe_handle_approval_policy(
|
||||
state: &Arc<AppState>,
|
||||
session: &SessionState,
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
tool: &PublishedAgentTool,
|
||||
arguments: &Value,
|
||||
transport_correlation: &CorrelationContext,
|
||||
) -> Option<ApprovalPolicyResult> {
|
||||
let policy = tool.operation.execution_config.approval_policy.as_ref()?;
|
||||
if !policy.required {
|
||||
return None;
|
||||
}
|
||||
|
||||
match policy.mode {
|
||||
OperationApprovalMode::Custom => {
|
||||
maybe_create_custom_pending_approval(
|
||||
state,
|
||||
session,
|
||||
message,
|
||||
response_mode,
|
||||
tool,
|
||||
arguments,
|
||||
transport_correlation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
OperationApprovalMode::Elicitation => Some(handle_elicitation_approval(
|
||||
session,
|
||||
message,
|
||||
response_mode,
|
||||
tool,
|
||||
arguments,
|
||||
policy.elicitation_message.as_deref(),
|
||||
transport_correlation,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_create_custom_pending_approval(
|
||||
state: &Arc<AppState>,
|
||||
session: &SessionState,
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
tool: &PublishedAgentTool,
|
||||
arguments: &Value,
|
||||
transport_correlation: &CorrelationContext,
|
||||
) -> Option<ApprovalPolicyResult> {
|
||||
let transport_request_id = transport_correlation.request_id().as_str();
|
||||
let policy = tool.operation.execution_config.approval_policy.as_ref()?;
|
||||
|
||||
let approval_id = ApprovalRequestId::new(format!("approval_{}", uuid::Uuid::now_v7().simple()));
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let expires_at = now + time::Duration::seconds(i64::from(policy.ttl_seconds));
|
||||
let approval = ApprovalRequest {
|
||||
id: approval_id,
|
||||
workspace_id: tool.workspace_id.clone(),
|
||||
agent_id: tool.agent_id.clone(),
|
||||
operation_id: tool.operation.id.clone(),
|
||||
operation_version: tool.operation.version,
|
||||
status: ApprovalRequestStatus::Pending,
|
||||
risk_level: policy.risk_level,
|
||||
request_payload: arguments.clone(),
|
||||
response_payload: None,
|
||||
created_at: now,
|
||||
expires_at,
|
||||
decided_at: None,
|
||||
decided_by_key_id: None,
|
||||
decision_note: None,
|
||||
};
|
||||
|
||||
let persisted_approval = match observe_db_query(
|
||||
DbOperation::ApprovalWrite,
|
||||
state
|
||||
.registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(approval) => approval,
|
||||
Err(error) => {
|
||||
return Some(ApprovalPolicyResult::Error(internal_jsonrpc_error(
|
||||
message, error,
|
||||
)));
|
||||
}
|
||||
};
|
||||
let response_payload = approval_required_response(tool, &persisted_approval.approval, policy);
|
||||
|
||||
persist_invocation(
|
||||
state,
|
||||
tool,
|
||||
InvocationRecord {
|
||||
request_id: Some(transport_request_id),
|
||||
trace_id: Some(transport_correlation.trace_id().as_str()),
|
||||
tool_name: &tool.tool_name,
|
||||
status: InvocationStatus::Ok,
|
||||
level: InvocationLevel::Info,
|
||||
message: "agent tool call is waiting for human approval",
|
||||
status_code: None,
|
||||
error_kind: None,
|
||||
duration: Duration::from_millis(0),
|
||||
request_preview: arguments.clone(),
|
||||
response_preview: response_payload.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Some(ApprovalPolicyResult::Required(success_tool_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
response_payload,
|
||||
)))
|
||||
}
|
||||
|
||||
fn handle_elicitation_approval(
|
||||
session: &SessionState,
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
tool: &PublishedAgentTool,
|
||||
arguments: &Value,
|
||||
elicitation_message: Option<&str>,
|
||||
transport_correlation: &CorrelationContext,
|
||||
) -> ApprovalPolicyResult {
|
||||
let transport_request_id = transport_correlation.request_id().as_str();
|
||||
if !session.supports_elicitation {
|
||||
return ApprovalPolicyResult::Error(tool_error_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
generic_tool_error_contract(
|
||||
"approval_elicitation_not_supported",
|
||||
"operation requires MCP Elicitation, but the MCP client did not advertise elicitation capability",
|
||||
transport_request_id,
|
||||
transport_correlation.trace_id().as_str(),
|
||||
false,
|
||||
Some(
|
||||
"Выберите Custom MCP Approval или подключите MCP-клиент с поддержкой elicitation.",
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let payload_preview = tool
|
||||
.operation
|
||||
.execution_config
|
||||
.approval_policy
|
||||
.as_ref()
|
||||
.and_then(|policy| policy.show_payload_preview.then(|| arguments.clone()))
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
ApprovalPolicyResult::Required(success_tool_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
json!({
|
||||
"status": "elicitation_required",
|
||||
"message": elicitation_message.unwrap_or("Confirm operation execution."),
|
||||
"tool": tool.tool_name,
|
||||
"payload_preview": payload_preview,
|
||||
"note": "This MCP client advertised elicitation support. Full elicitation/create continuation is handled by compatible client integrations.",
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn handle_initialize(
|
||||
state: Arc<AppState>,
|
||||
path: &AgentRoutePath,
|
||||
@@ -1548,91 +1467,6 @@ fn internal_jsonrpc_error(message: &Value, _error: impl std::fmt::Display) -> Re
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_confirmation_token(arguments: &mut Value) -> Option<String> {
|
||||
let Value::Object(object) = arguments else {
|
||||
return None;
|
||||
};
|
||||
object
|
||||
.remove("_crank_confirmation_token")
|
||||
.and_then(|value| value.as_str().map(str::to_owned))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn build_request_preview(
|
||||
runtime: &RuntimeExecutor,
|
||||
operation: &RuntimeOperation,
|
||||
arguments: &Value,
|
||||
) -> Value {
|
||||
match runtime.prepare_request(operation, arguments) {
|
||||
Ok(prepared) => json!({
|
||||
"path": prepared.path_params,
|
||||
"query": prepared.query_params,
|
||||
"headers": prepared.headers,
|
||||
"body": prepared.body.unwrap_or(Value::Null)
|
||||
}),
|
||||
Err(_) => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn success_tool_response(
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
protocol_version: &str,
|
||||
output: Value,
|
||||
) -> Response {
|
||||
transport_response(
|
||||
StatusCode::OK,
|
||||
jsonrpc_result(
|
||||
request_id(message),
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_owned())
|
||||
}
|
||||
],
|
||||
"structuredContent": output,
|
||||
"isError": false
|
||||
}),
|
||||
),
|
||||
response_mode,
|
||||
None,
|
||||
Some(protocol_version),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn tool_error_response(
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
protocol_version: &str,
|
||||
error: ToolErrorContract,
|
||||
) -> Response {
|
||||
let error_message = tool_error_text(&error);
|
||||
let error_value = tool_error_value(&error);
|
||||
|
||||
transport_response(
|
||||
StatusCode::OK,
|
||||
jsonrpc_result(
|
||||
request_id(message),
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": error_message
|
||||
}
|
||||
],
|
||||
"structuredContent": {
|
||||
"error": error_value
|
||||
},
|
||||
"isError": true
|
||||
}),
|
||||
),
|
||||
response_mode,
|
||||
None,
|
||||
Some(protocol_version),
|
||||
)
|
||||
}
|
||||
|
||||
fn add_millis(timestamp: OffsetDateTime, millis: u64) -> OffsetDateTime {
|
||||
let delta = time::Duration::milliseconds(i64::try_from(millis).unwrap_or(i64::MAX));
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use axum::response::Response;
|
||||
use crank_core::{
|
||||
ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, CorrelationContext, InvocationLevel,
|
||||
InvocationStatus, OperationApprovalMode,
|
||||
};
|
||||
use crank_registry::{CreateApprovalRequest, PublishedAgentTool};
|
||||
use crank_trace::{DbOperation, observe_db_query};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use super::{
|
||||
AppState, InvocationRecord, internal_jsonrpc_error, persist_invocation_for_key,
|
||||
response::{success_tool_response, tool_error_response},
|
||||
};
|
||||
use crate::{
|
||||
approval_response::{
|
||||
approval_history_request_preview, approval_history_response_preview,
|
||||
approval_required_response,
|
||||
},
|
||||
session::SessionState,
|
||||
tool_error::{McpControlError, control_tool_error_contract, execution_locale},
|
||||
transport::ResponseMode,
|
||||
};
|
||||
|
||||
pub(super) enum ApprovalPolicyResult {
|
||||
Required(Response),
|
||||
Error(Response),
|
||||
}
|
||||
|
||||
pub(super) struct ApprovalPolicyContext<'a> {
|
||||
pub state: &'a Arc<AppState>,
|
||||
pub session: &'a SessionState,
|
||||
pub message: &'a Value,
|
||||
pub response_mode: ResponseMode,
|
||||
pub tool: &'a PublishedAgentTool,
|
||||
pub arguments: &'a Value,
|
||||
pub platform_api_key_id: Option<&'a crank_core::PlatformApiKeyId>,
|
||||
pub transport_correlation: &'a CorrelationContext,
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_handle_approval_policy(
|
||||
context: ApprovalPolicyContext<'_>,
|
||||
) -> Option<ApprovalPolicyResult> {
|
||||
let policy = context
|
||||
.tool
|
||||
.operation
|
||||
.execution_config
|
||||
.approval_policy
|
||||
.as_ref()?;
|
||||
if !policy.required {
|
||||
return None;
|
||||
}
|
||||
|
||||
match policy.mode {
|
||||
OperationApprovalMode::Custom => maybe_create_custom_pending_approval(&context).await,
|
||||
OperationApprovalMode::Elicitation => Some(handle_elicitation_approval(
|
||||
context.session,
|
||||
context.message,
|
||||
context.response_mode,
|
||||
context.tool,
|
||||
context.arguments,
|
||||
policy.elicitation_message.as_deref(),
|
||||
context.transport_correlation,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_create_custom_pending_approval(
|
||||
context: &ApprovalPolicyContext<'_>,
|
||||
) -> Option<ApprovalPolicyResult> {
|
||||
let ApprovalPolicyContext {
|
||||
state,
|
||||
session,
|
||||
message,
|
||||
response_mode,
|
||||
tool,
|
||||
arguments,
|
||||
platform_api_key_id,
|
||||
transport_correlation,
|
||||
} = context;
|
||||
let transport_request_id = transport_correlation.request_id().as_str();
|
||||
let policy = tool.operation.execution_config.approval_policy.as_ref()?;
|
||||
|
||||
let approval_id = ApprovalRequestId::new(format!("approval_{}", uuid::Uuid::now_v7().simple()));
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let expires_at = now + time::Duration::seconds(i64::from(policy.ttl_seconds));
|
||||
let approval = ApprovalRequest {
|
||||
id: approval_id,
|
||||
workspace_id: tool.workspace_id.clone(),
|
||||
agent_id: tool.agent_id.clone(),
|
||||
operation_id: tool.operation.id.clone(),
|
||||
operation_version: tool.operation.version,
|
||||
status: ApprovalRequestStatus::Pending,
|
||||
risk_level: policy.risk_level,
|
||||
request_id: Some(transport_correlation.request_id().as_str().to_owned()),
|
||||
trace_id: Some(transport_correlation.trace_id().as_str().to_owned()),
|
||||
request_payload: (*arguments).clone(),
|
||||
response_payload: None,
|
||||
created_at: now,
|
||||
expires_at,
|
||||
decided_at: None,
|
||||
decided_by_key_id: None,
|
||||
decision_note: None,
|
||||
};
|
||||
|
||||
let persisted_approval = match observe_db_query(
|
||||
DbOperation::ApprovalWrite,
|
||||
state
|
||||
.registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(approval) => approval,
|
||||
Err(error) => {
|
||||
return Some(ApprovalPolicyResult::Error(internal_jsonrpc_error(
|
||||
message, error,
|
||||
)));
|
||||
}
|
||||
};
|
||||
let response_payload = approval_required_response(tool, &persisted_approval.approval, policy);
|
||||
|
||||
persist_invocation_for_key(
|
||||
state,
|
||||
tool,
|
||||
*platform_api_key_id,
|
||||
InvocationRecord {
|
||||
request_id: Some(transport_request_id),
|
||||
trace_id: Some(transport_correlation.trace_id().as_str()),
|
||||
tool_name: &tool.tool_name,
|
||||
status: InvocationStatus::Ok,
|
||||
level: InvocationLevel::Info,
|
||||
message: "agent tool call is waiting for human approval",
|
||||
status_code: None,
|
||||
error_kind: None,
|
||||
execution_stage: Some(crank_core::ExecutionStage::Admission),
|
||||
execution_error_code: None,
|
||||
retryability: Some(crank_core::Retryability::RequiresConfirmation),
|
||||
outcome_certainty: Some(crank_core::OutcomeCertainty::Certain),
|
||||
duration: Duration::from_millis(0),
|
||||
request_preview: approval_history_request_preview(&persisted_approval.approval),
|
||||
response_preview: approval_history_response_preview(&response_payload),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Some(ApprovalPolicyResult::Required(success_tool_response(
|
||||
message,
|
||||
*response_mode,
|
||||
&session.protocol_version,
|
||||
response_payload,
|
||||
)))
|
||||
}
|
||||
|
||||
fn handle_elicitation_approval(
|
||||
session: &SessionState,
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
tool: &PublishedAgentTool,
|
||||
arguments: &Value,
|
||||
elicitation_message: Option<&str>,
|
||||
transport_correlation: &CorrelationContext,
|
||||
) -> ApprovalPolicyResult {
|
||||
let transport_request_id = transport_correlation.request_id().as_str();
|
||||
if !session.supports_elicitation {
|
||||
return ApprovalPolicyResult::Error(tool_error_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
control_tool_error_contract(
|
||||
McpControlError::ApprovalElicitationNotSupported,
|
||||
transport_request_id,
|
||||
transport_correlation.trace_id().as_str(),
|
||||
execution_locale(message),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let payload_preview = tool
|
||||
.operation
|
||||
.execution_config
|
||||
.approval_policy
|
||||
.as_ref()
|
||||
.and_then(|policy| policy.show_payload_preview.then(|| arguments.clone()))
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
ApprovalPolicyResult::Required(success_tool_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
json!({
|
||||
"status": "elicitation_required",
|
||||
"message": elicitation_message.unwrap_or("Confirm operation execution."),
|
||||
"tool": tool.tool_name,
|
||||
"payload_preview": payload_preview,
|
||||
"note": "This MCP client advertised elicitation support. Full elicitation/create continuation is handled by compatible client integrations.",
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -22,6 +22,10 @@ pub(crate) struct InvocationRecord<'a> {
|
||||
pub(crate) message: &'a str,
|
||||
pub(crate) status_code: Option<u16>,
|
||||
pub(crate) error_kind: Option<&'a str>,
|
||||
pub(crate) execution_stage: Option<crank_core::ExecutionStage>,
|
||||
pub(crate) execution_error_code: Option<crank_core::ExecutionErrorCode>,
|
||||
pub(crate) retryability: Option<crank_core::Retryability>,
|
||||
pub(crate) outcome_certainty: Option<crank_core::OutcomeCertainty>,
|
||||
pub(crate) duration: Duration,
|
||||
pub(crate) request_preview: Value,
|
||||
pub(crate) response_preview: Value,
|
||||
@@ -31,12 +35,23 @@ pub(crate) async fn persist_invocation(
|
||||
state: &Arc<AppState>,
|
||||
tool: &PublishedAgentTool,
|
||||
record: InvocationRecord<'_>,
|
||||
) -> InvocationHistoryWriteOutcome {
|
||||
persist_invocation_for_key(state, tool, None, record).await
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_invocation_for_key(
|
||||
state: &Arc<AppState>,
|
||||
tool: &PublishedAgentTool,
|
||||
platform_api_key_id: Option<&crank_core::PlatformApiKeyId>,
|
||||
record: InvocationRecord<'_>,
|
||||
) -> InvocationHistoryWriteOutcome {
|
||||
let log = InvocationLog {
|
||||
id: InvocationLogId::new(format!("log_{}", uuid::Uuid::now_v7().simple())),
|
||||
workspace_id: tool.workspace_id.clone(),
|
||||
agent_id: Some(tool.agent_id.clone()),
|
||||
platform_api_key_id: platform_api_key_id.cloned(),
|
||||
operation_id: tool.operation.id.clone(),
|
||||
operation_version: Some(tool.operation.version),
|
||||
source: InvocationSource::AgentToolCall,
|
||||
level: record.level,
|
||||
status: record.status,
|
||||
@@ -47,6 +62,10 @@ pub(crate) async fn persist_invocation(
|
||||
status_code: record.status_code,
|
||||
duration_ms: u64::try_from(record.duration.as_millis()).unwrap_or(u64::MAX),
|
||||
error_kind: record.error_kind.map(ToOwned::to_owned),
|
||||
execution_stage: record.execution_stage,
|
||||
execution_error_code: record.execution_error_code,
|
||||
retryability: record.retryability,
|
||||
outcome_certainty: record.outcome_certainty,
|
||||
request_preview: record.request_preview,
|
||||
response_preview: record.response_preview,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct InitializeParams {
|
||||
#[serde(rename = "protocolVersion")]
|
||||
pub(super) protocol_version: String,
|
||||
#[serde(default)]
|
||||
pub(super) capabilities: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct ToolCallParams {
|
||||
pub(super) name: String,
|
||||
#[serde(default)]
|
||||
pub(super) arguments: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ToolsListParams {
|
||||
#[serde(default)]
|
||||
pub(super) cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub(super) fn paginate_tools_list(
|
||||
definitions: Vec<Value>,
|
||||
params: ToolsListParams,
|
||||
) -> Result<Value, &'static str> {
|
||||
const MAX_TOOLS_LIST_LIMIT: usize = 100;
|
||||
let start = match params.cursor {
|
||||
Some(cursor) if cursor.is_empty() => 0,
|
||||
Some(cursor) => cursor
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "invalid tools/list cursor")?,
|
||||
None => 0,
|
||||
};
|
||||
if start > definitions.len() {
|
||||
return Err("tools/list cursor is out of range");
|
||||
}
|
||||
let limit = params
|
||||
.limit
|
||||
.unwrap_or(definitions.len())
|
||||
.clamp(1, MAX_TOOLS_LIST_LIMIT);
|
||||
let end = start.saturating_add(limit).min(definitions.len());
|
||||
let page = definitions[start..end].to_vec();
|
||||
let mut result = json!({ "tools": page });
|
||||
if end < definitions.len() {
|
||||
result["nextCursor"] = json!(end.to_string());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ApprovalDecisionPayload {
|
||||
pub(super) approve: String,
|
||||
#[serde(default)]
|
||||
pub(super) note: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use axum::{http::StatusCode, response::Response};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
jsonrpc::{jsonrpc_result, request_id},
|
||||
tool_error::{ToolErrorContract, tool_error_text, tool_error_value},
|
||||
transport::{ResponseMode, transport_response},
|
||||
};
|
||||
|
||||
pub(crate) fn take_confirmation_token(arguments: &mut Value) -> Option<String> {
|
||||
let Value::Object(object) = arguments else {
|
||||
return None;
|
||||
};
|
||||
object
|
||||
.remove("_crank_confirmation_token")
|
||||
.and_then(|value| value.as_str().map(str::to_owned))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn success_tool_response(
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
protocol_version: &str,
|
||||
output: Value,
|
||||
) -> Response {
|
||||
transport_response(
|
||||
StatusCode::OK,
|
||||
jsonrpc_result(
|
||||
request_id(message),
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_owned())
|
||||
}
|
||||
],
|
||||
"structuredContent": output,
|
||||
"isError": false
|
||||
}),
|
||||
),
|
||||
response_mode,
|
||||
None,
|
||||
Some(protocol_version),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn tool_error_response(
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
protocol_version: &str,
|
||||
error: ToolErrorContract,
|
||||
) -> Response {
|
||||
let error_message = tool_error_text(&error);
|
||||
let error_value = tool_error_value(&error);
|
||||
|
||||
transport_response(
|
||||
StatusCode::OK,
|
||||
jsonrpc_result(
|
||||
request_id(message),
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": error_message
|
||||
}
|
||||
],
|
||||
"structuredContent": {
|
||||
"error": error_value
|
||||
},
|
||||
"isError": true
|
||||
}),
|
||||
),
|
||||
response_mode,
|
||||
None,
|
||||
Some(protocol_version),
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use super::{
|
||||
observe_invocation_history_outcome, tool_error_response,
|
||||
};
|
||||
use crate::jsonrpc::{CURRENT_PROTOCOL_VERSION, jsonrpc_error};
|
||||
use crate::tool_error::generic_tool_error_contract;
|
||||
use crate::tool_error::{McpControlError, control_tool_error_contract};
|
||||
use crate::transport::transport_response;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -33,13 +33,11 @@ async fn tool_error_response_includes_structured_context() {
|
||||
&message,
|
||||
ResponseMode::Json,
|
||||
CURRENT_PROTOCOL_VERSION,
|
||||
generic_tool_error_contract(
|
||||
"streaming_payload_error",
|
||||
"request root must be an object",
|
||||
control_tool_error_contract(
|
||||
McpControlError::CatalogRevisionChanged,
|
||||
"req-1",
|
||||
"0af7651916cd43dd8448eb211c80319c",
|
||||
false,
|
||||
Some("Проверьте параметры вызова инструмента."),
|
||||
crank_core::ExecutionLocale::Ru,
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -55,13 +53,16 @@ async fn tool_error_response_includes_structured_context() {
|
||||
assert_eq!(
|
||||
payload["result"]["structuredContent"]["error"],
|
||||
json!({
|
||||
"code": "streaming_payload_error",
|
||||
"error_code": "streaming_payload_error",
|
||||
"message": "request root must be an object",
|
||||
"recoverable": false,
|
||||
"code": "agent_catalog_result_stale",
|
||||
"error_code": "agent_catalog_result_stale",
|
||||
"message": "Версия каталога изменилась.",
|
||||
"stage": "authorization",
|
||||
"retryability": "after_delay",
|
||||
"outcome_certainty": "certain",
|
||||
"recoverable": true,
|
||||
"request_id": "req-1",
|
||||
"trace_id": "0af7651916cd43dd8448eb211c80319c",
|
||||
"suggested_action": "Проверьте параметры вызова инструмента."
|
||||
"suggested_action": "Повторите search_tools и вызовите инструмент с новой версией каталога."
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,16 +9,13 @@ use crank_core::{CorrelationContext, RequestId, TraceContext};
|
||||
use crank_registry::{ApprovalRequestRecord, FinishApprovalRequest};
|
||||
use crank_runtime::{RuntimeExecutionRequest, RuntimeRequestContext};
|
||||
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{Instrument, warn};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
AgentRoutePath, AppState, InvocationRecord, build_request_preview, persist_invocation,
|
||||
resolve_operation_auth, runtime_operation,
|
||||
},
|
||||
tool_error::{runtime_error_code, safe_runtime_error_message},
|
||||
use crate::app::{
|
||||
AgentRoutePath, AppState, InvocationRecord, persist_invocation, resolve_operation_auth,
|
||||
runtime_operation,
|
||||
};
|
||||
|
||||
const RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
@@ -184,11 +181,6 @@ pub(super) async fn execute_approved_request(
|
||||
};
|
||||
|
||||
let operation = runtime_operation(&tool);
|
||||
let request_preview = build_request_preview(
|
||||
&state.runtime,
|
||||
&operation,
|
||||
&approval.approval.request_payload,
|
||||
);
|
||||
let started_at = Instant::now();
|
||||
let runtime_request_context = RuntimeRequestContext::from_correlation(&correlation)
|
||||
.with_response_cache_scope(
|
||||
@@ -205,42 +197,86 @@ pub(super) async fn execute_approved_request(
|
||||
resolve_operation_auth(state, &tool.workspace_id, &operation.execution_config).await;
|
||||
let result = match resolved_auth {
|
||||
Ok(resolved_auth) => {
|
||||
state
|
||||
.runtime
|
||||
.execute_request(
|
||||
RuntimeExecutionRequest::new(&operation, &approval.approval.request_payload)
|
||||
.with_optional_auth(resolved_auth.as_ref())
|
||||
.with_context(&runtime_request_context),
|
||||
)
|
||||
.await
|
||||
match RuntimeExecutionRequest::try_new(
|
||||
&tool.workspace_id,
|
||||
crank_core::ExecutionOrigin::AgentSnapshot,
|
||||
Some(&tool.agent_id),
|
||||
&operation,
|
||||
&approval.approval.request_payload,
|
||||
crank_runtime::ExecutionAuthorization::Authorized,
|
||||
resolved_auth.as_ref(),
|
||||
&runtime_request_context,
|
||||
Instant::now()
|
||||
+ std::time::Duration::from_millis(
|
||||
operation.execution_config.timeout_ms.max(1),
|
||||
),
|
||||
) {
|
||||
Ok(request) => state.runtime.execute_outcome(request).await,
|
||||
Err(_) => Err(crank_core::ExecutionFailure::new(
|
||||
crank_core::ExecutionErrorCode::RuntimeInternal,
|
||||
correlation.clone(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
Err(error) => Err(crank_runtime::normalize_runtime_error(&error, &correlation)),
|
||||
};
|
||||
|
||||
let (status, response_payload, invocation_status, invocation_level, message, error_kind) =
|
||||
match result {
|
||||
Ok(output) => (
|
||||
let (
|
||||
status,
|
||||
response_payload,
|
||||
invocation_status,
|
||||
invocation_level,
|
||||
message,
|
||||
error_kind,
|
||||
execution_stage,
|
||||
execution_error_code,
|
||||
retryability,
|
||||
outcome_certainty,
|
||||
upstream_status,
|
||||
request_preview,
|
||||
) = match result {
|
||||
Ok(success) => {
|
||||
let request_preview = success.request_preview;
|
||||
(
|
||||
ApprovalRequestStatus::Completed,
|
||||
output,
|
||||
success.output,
|
||||
InvocationStatus::Ok,
|
||||
InvocationLevel::Info,
|
||||
"approved tool call completed",
|
||||
None,
|
||||
),
|
||||
Err(error) => (
|
||||
ApprovalRequestStatus::Failed,
|
||||
json!({
|
||||
"error": {
|
||||
"code": runtime_error_code(&error),
|
||||
"message": safe_runtime_error_message(&error),
|
||||
}
|
||||
}),
|
||||
InvocationStatus::Error,
|
||||
InvocationLevel::Error,
|
||||
"approved tool call failed",
|
||||
Some(runtime_error_code(&error)),
|
||||
),
|
||||
};
|
||||
Some(crank_core::ExecutionStage::Runtime),
|
||||
None,
|
||||
Some(crank_core::Retryability::Never),
|
||||
Some(crank_core::OutcomeCertainty::Certain),
|
||||
None,
|
||||
request_preview,
|
||||
)
|
||||
}
|
||||
Err(failure) => (
|
||||
ApprovalRequestStatus::Failed,
|
||||
json!({
|
||||
"error": {
|
||||
"code": failure.error_code().as_str(),
|
||||
"message": failure.error_code().message(crank_core::ExecutionLocale::Ru),
|
||||
"stage": failure.stage().as_str(),
|
||||
"retryability": failure.retryability().as_str(),
|
||||
"outcome_certainty": failure.outcome_certainty().as_str(),
|
||||
"request_id": request_id,
|
||||
"trace_id": correlation.trace_id().as_str(),
|
||||
}
|
||||
}),
|
||||
InvocationStatus::Error,
|
||||
InvocationLevel::Error,
|
||||
"approved tool call failed",
|
||||
Some(failure.error_code().as_str()),
|
||||
Some(failure.stage()),
|
||||
Some(failure.error_code()),
|
||||
Some(failure.retryability()),
|
||||
Some(failure.outcome_certainty()),
|
||||
failure.upstream_status(),
|
||||
Value::Null,
|
||||
),
|
||||
};
|
||||
|
||||
persist_invocation(
|
||||
state,
|
||||
@@ -252,8 +288,12 @@ pub(super) async fn execute_approved_request(
|
||||
status: invocation_status,
|
||||
level: invocation_level,
|
||||
message,
|
||||
status_code: None,
|
||||
status_code: upstream_status,
|
||||
error_kind,
|
||||
execution_stage,
|
||||
execution_error_code,
|
||||
retryability,
|
||||
outcome_certainty,
|
||||
duration: started_at.elapsed(),
|
||||
request_preview,
|
||||
response_preview: response_payload.clone(),
|
||||
@@ -275,8 +315,47 @@ pub(super) async fn execute_approved_request(
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
|
||||
.ok_or_else(|| StatusCode::CONFLICT.into_response())
|
||||
.map_err(|_| approved_completion_persistence_error(&correlation))?
|
||||
.ok_or_else(|| approved_completion_conflict_error(&correlation))
|
||||
}
|
||||
|
||||
fn approved_completion_conflict_error(correlation: &CorrelationContext) -> Response {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
axum::Json(json!({
|
||||
"error": {
|
||||
"code": "approval_state_conflict",
|
||||
"stage": "mandatory_persistence",
|
||||
"retryability": "manual_reconcile",
|
||||
"outcome_certainty": "outcome_unknown",
|
||||
"request_id": correlation.request_id().as_str(),
|
||||
"trace_id": correlation.trace_id().as_str(),
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn approved_completion_persistence_error(correlation: &CorrelationContext) -> Response {
|
||||
let failure = crank_core::ExecutionFailure::new(
|
||||
crank_core::ExecutionErrorCode::PersistenceUnavailable,
|
||||
correlation.clone(),
|
||||
)
|
||||
.with_dispatch_uncertainty();
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
axum::Json(json!({
|
||||
"error": {
|
||||
"code": failure.error_code().as_str(),
|
||||
"stage": failure.stage().as_str(),
|
||||
"retryability": failure.retryability().as_str(),
|
||||
"outcome_certainty": failure.outcome_certainty().as_str(),
|
||||
"request_id": failure.correlation().request_id().as_str(),
|
||||
"trace_id": failure.correlation().trace_id().as_str(),
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn finish_unavailable_approval(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crank_core::{ApprovalRequest, OperationApprovalPolicy};
|
||||
use crank_core::{ApprovalRequest, OperationApprovalPolicy, sanitize_invocation_preview};
|
||||
use crank_registry::PublishedAgentTool;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -30,9 +30,25 @@ pub(super) fn approval_required_response(
|
||||
"expires_at": approval.expires_at,
|
||||
"risk_level": approval.risk_level,
|
||||
"payload_preview": if policy.show_payload_preview {
|
||||
approval.request_payload.clone()
|
||||
sanitize_invocation_preview(&approval.request_payload)
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn approval_history_request_preview(approval: &ApprovalRequest) -> Value {
|
||||
json!({
|
||||
"status": "approval_required",
|
||||
"approval_id": approval.id.as_str(),
|
||||
"risk_level": approval.risk_level,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn approval_history_response_preview(response: &Value) -> Value {
|
||||
json!({
|
||||
"status": response.get("status").cloned().unwrap_or(Value::Null),
|
||||
"approval_id": response.get("approval_id").cloned().unwrap_or(Value::Null),
|
||||
"risk_level": response.get("risk_level").cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,14 +108,7 @@ impl PublishedToolCatalog {
|
||||
agent_slug: &str,
|
||||
) -> Result<(), RegistryError> {
|
||||
let key = CatalogKey::new(workspace_slug, agent_slug);
|
||||
let should_refresh = {
|
||||
let guard = self.cached.read().await;
|
||||
|
||||
match guard.get(&key).and_then(|entry| entry.loaded_at) {
|
||||
Some(loaded_at) => loaded_at.elapsed() >= self.refresh_interval,
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
let should_refresh = true;
|
||||
|
||||
if !should_refresh {
|
||||
return Ok(());
|
||||
@@ -133,32 +126,11 @@ impl PublishedToolCatalog {
|
||||
}
|
||||
};
|
||||
let _refresh_guard = refresh_lock.lock().await;
|
||||
let still_stale = {
|
||||
let guard = self.cached.read().await;
|
||||
match guard.get(&key).and_then(|entry| entry.loaded_at) {
|
||||
Some(loaded_at) => loaded_at.elapsed() >= self.refresh_interval,
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
let still_stale = true;
|
||||
if !still_stale {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some((catalog, age)) = self.load_shared_snapshot(workspace_slug, agent_slug).await {
|
||||
let metrics =
|
||||
log_catalog_analysis(workspace_slug, agent_slug, "shared_cache", &catalog.tools);
|
||||
self.store_local_catalog(
|
||||
key,
|
||||
CachedCatalog {
|
||||
loaded_at: Instant::now().checked_sub(age),
|
||||
catalog,
|
||||
metrics,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let db_span = DbOperation::CatalogLoad.span();
|
||||
let catalog_result = self
|
||||
.registry
|
||||
@@ -224,29 +196,6 @@ impl PublishedToolCatalog {
|
||||
previous_count
|
||||
}
|
||||
|
||||
async fn load_shared_snapshot(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Option<(PublishedAgentCatalog, Duration)> {
|
||||
if self.refresh_interval.is_zero() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let key = catalog_snapshot_key(workspace_slug, agent_slug);
|
||||
let value = match self
|
||||
.coordination_store
|
||||
.get_value(CacheScope::Coordination, &key)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value?,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let snapshot = serde_json::from_value::<CatalogSnapshot>(value.payload).ok()?;
|
||||
let age = Duration::from_millis(now_unix_ms().saturating_sub(snapshot.generated_at_ms));
|
||||
(age < self.refresh_interval).then_some((snapshot.catalog, age))
|
||||
}
|
||||
|
||||
async fn store_shared_snapshot(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crank_adapter_rest::RestAdapterError;
|
||||
use crank_runtime::RuntimeError;
|
||||
use crank_core::{
|
||||
CorrelationContext, ExecutionFailure, ExecutionLocale, RequestId, Retryability, TraceContext,
|
||||
};
|
||||
use crank_runtime::{RuntimeError, normalize_runtime_error};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -7,6 +9,9 @@ use serde_json::{Value, json};
|
||||
pub struct ToolErrorContract {
|
||||
pub code: &'static str,
|
||||
pub error_code: &'static str,
|
||||
pub stage: &'static str,
|
||||
pub retryability: &'static str,
|
||||
pub outcome_certainty: &'static str,
|
||||
pub message: String,
|
||||
pub recoverable: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -21,32 +26,95 @@ pub fn tool_error_contract_from_runtime(
|
||||
error: &RuntimeError,
|
||||
request_id: &str,
|
||||
trace_id: &str,
|
||||
locale: ExecutionLocale,
|
||||
) -> ToolErrorContract {
|
||||
let error_code = runtime_error_code(error);
|
||||
let correlation = correlation_from_safe_ids(request_id, trace_id);
|
||||
project_failure(&normalize_runtime_error(error, &correlation), locale)
|
||||
}
|
||||
|
||||
pub fn tool_error_contract_from_failure(
|
||||
failure: &ExecutionFailure,
|
||||
locale: ExecutionLocale,
|
||||
) -> ToolErrorContract {
|
||||
project_failure(failure, locale)
|
||||
}
|
||||
|
||||
fn project_failure(failure: &ExecutionFailure, locale: ExecutionLocale) -> ToolErrorContract {
|
||||
let code = failure.error_code();
|
||||
let retryability = failure.retryability();
|
||||
let message = if let Some(challenge) = failure.confirmation() {
|
||||
confirmation_message(
|
||||
code.message(locale),
|
||||
challenge.token(),
|
||||
challenge.expires_in_ms(),
|
||||
locale,
|
||||
)
|
||||
} else {
|
||||
code.message(locale).to_owned()
|
||||
};
|
||||
ToolErrorContract {
|
||||
code: error_code,
|
||||
error_code,
|
||||
message: safe_runtime_error_message(error),
|
||||
recoverable: is_recoverable_runtime_error(error),
|
||||
suggested_action: suggested_action(error),
|
||||
upstream_status: upstream_status(error),
|
||||
request_id: request_id.to_owned(),
|
||||
trace_id: trace_id.to_owned(),
|
||||
code: code.as_str(),
|
||||
error_code: code.as_str(),
|
||||
stage: failure.stage().as_str(),
|
||||
retryability: retryability.as_str(),
|
||||
outcome_certainty: failure.outcome_certainty().as_str(),
|
||||
message,
|
||||
recoverable: matches!(
|
||||
retryability,
|
||||
Retryability::Safe | Retryability::AfterDelay | Retryability::RequiresConfirmation
|
||||
),
|
||||
suggested_action: suggested_action(retryability, locale),
|
||||
upstream_status: failure.upstream_status(),
|
||||
request_id: failure.correlation().request_id().to_string(),
|
||||
trace_id: failure.correlation().trace_id().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generic_tool_error_contract(
|
||||
error_code: &'static str,
|
||||
message: impl Into<String>,
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum McpControlError {
|
||||
CatalogRevisionChanged,
|
||||
ApprovalElicitationNotSupported,
|
||||
}
|
||||
|
||||
pub fn control_tool_error_contract(
|
||||
error: McpControlError,
|
||||
request_id: &str,
|
||||
trace_id: &str,
|
||||
recoverable: bool,
|
||||
suggested_action: Option<&'static str>,
|
||||
locale: ExecutionLocale,
|
||||
) -> ToolErrorContract {
|
||||
let (error_code, message, recoverable, suggested_action) = match (error, locale) {
|
||||
(McpControlError::CatalogRevisionChanged, ExecutionLocale::Ru) => (
|
||||
"agent_catalog_result_stale",
|
||||
"Версия каталога изменилась.",
|
||||
true,
|
||||
Some("Повторите search_tools и вызовите инструмент с новой версией каталога."),
|
||||
),
|
||||
(McpControlError::CatalogRevisionChanged, ExecutionLocale::En) => (
|
||||
"agent_catalog_result_stale",
|
||||
"The catalog revision changed.",
|
||||
true,
|
||||
Some("Run search_tools again and call the tool with the new catalog revision."),
|
||||
),
|
||||
(McpControlError::ApprovalElicitationNotSupported, ExecutionLocale::Ru) => (
|
||||
"approval_elicitation_not_supported",
|
||||
"Клиент не поддерживает MCP Elicitation, обязательный для этой операции.",
|
||||
false,
|
||||
Some("Используйте Custom MCP Approval или клиент с поддержкой elicitation."),
|
||||
),
|
||||
(McpControlError::ApprovalElicitationNotSupported, ExecutionLocale::En) => (
|
||||
"approval_elicitation_not_supported",
|
||||
"The client does not support MCP Elicitation required by this operation.",
|
||||
false,
|
||||
Some("Use Custom MCP Approval or an elicitation-capable client."),
|
||||
),
|
||||
};
|
||||
ToolErrorContract {
|
||||
code: error_code,
|
||||
error_code,
|
||||
message: message.into(),
|
||||
stage: "authorization",
|
||||
retryability: if recoverable { "after_delay" } else { "never" },
|
||||
outcome_certainty: "certain",
|
||||
message: message.to_owned(),
|
||||
recoverable,
|
||||
suggested_action,
|
||||
upstream_status: None,
|
||||
@@ -55,6 +123,19 @@ pub fn generic_tool_error_contract(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execution_locale(message: &Value) -> ExecutionLocale {
|
||||
let locale = message
|
||||
.pointer("/params/_meta/locale")
|
||||
.or_else(|| message.pointer("/params/locale"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("en");
|
||||
if locale.eq_ignore_ascii_case("ru") || locale.to_ascii_lowercase().starts_with("ru-") {
|
||||
ExecutionLocale::Ru
|
||||
} else {
|
||||
ExecutionLocale::En
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_error_text(error: &ToolErrorContract) -> String {
|
||||
match error.suggested_action {
|
||||
Some(action) => format!("{} {}", error.message, action),
|
||||
@@ -65,9 +146,12 @@ pub fn tool_error_text(error: &ToolErrorContract) -> String {
|
||||
pub fn tool_error_value(error: &ToolErrorContract) -> Value {
|
||||
serde_json::to_value(error).unwrap_or_else(|_| {
|
||||
json!({
|
||||
"code": "runtime_error",
|
||||
"error_code": "runtime_error",
|
||||
"message": "Не удалось выполнить инструмент.",
|
||||
"code": "runtime_internal",
|
||||
"error_code": "runtime_internal",
|
||||
"stage": "runtime",
|
||||
"retryability": "never",
|
||||
"outcome_certainty": "certain",
|
||||
"message": "Внутренняя ошибка выполнения.",
|
||||
"recoverable": false,
|
||||
"request_id": error.request_id,
|
||||
"trace_id": error.trace_id
|
||||
@@ -75,178 +159,54 @@ pub fn tool_error_value(error: &ToolErrorContract) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn runtime_error_code(error: &RuntimeError) -> &'static str {
|
||||
match error {
|
||||
RuntimeError::Schema(_) => "schema_validation_error",
|
||||
RuntimeError::Mapping(_) => "mapping_error",
|
||||
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus { status, .. }) => {
|
||||
upstream_status_code(*status)
|
||||
}
|
||||
RuntimeError::RestAdapter(RestAdapterError::Transport(_)) => "upstream_transport_error",
|
||||
RuntimeError::RestAdapter(_) => "adapter_execution_error",
|
||||
RuntimeError::ProtocolAdapter(_) => "adapter_execution_error",
|
||||
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
||||
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_error",
|
||||
RuntimeError::ConfirmationRequired { .. } => "confirmation_required",
|
||||
RuntimeError::InvalidConfirmationToken { .. } => "invalid_confirmation_token",
|
||||
RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation_unavailable",
|
||||
RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency_unavailable",
|
||||
RuntimeError::IdempotencyInProgress { .. } => "idempotency_in_progress",
|
||||
RuntimeError::IdempotencyConflict { .. } => "idempotency_conflict",
|
||||
RuntimeError::IdempotencyOutcomeUnknown { .. } => "idempotency_outcome_unknown",
|
||||
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"secret_not_found"
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error",
|
||||
RuntimeError::SecretCrypto { .. } => "secret_crypto_error",
|
||||
}
|
||||
fn correlation_from_safe_ids(request_id: &str, trace_id: &str) -> CorrelationContext {
|
||||
let request_id = RequestId::parse(request_id).unwrap_or_else(|_| RequestId::generate());
|
||||
let trace_context = TraceContext::from_span_parts(trace_id, "0000000000000001", false)
|
||||
.unwrap_or_else(|_| TraceContext::generate());
|
||||
CorrelationContext::new(request_id, trace_context)
|
||||
}
|
||||
|
||||
fn upstream_status_code(status: u16) -> &'static str {
|
||||
match status {
|
||||
401 | 403 => "upstream_auth_error",
|
||||
404 => "upstream_not_found",
|
||||
408 | 429 => "upstream_rate_limited",
|
||||
500..=599 => "upstream_server_error",
|
||||
_ => "upstream_status_error",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn safe_runtime_error_message(error: &RuntimeError) -> String {
|
||||
match error {
|
||||
RuntimeError::Schema(_) => "Входные параметры не прошли проверку схемы.".to_owned(),
|
||||
RuntimeError::Mapping(_) => {
|
||||
"Не удалось сопоставить параметры инструмента с API-запросом.".to_owned()
|
||||
}
|
||||
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus { status, .. }) => {
|
||||
format!("Внешний API вернул HTTP {}.", status)
|
||||
}
|
||||
RuntimeError::RestAdapter(RestAdapterError::Transport(_)) => {
|
||||
"Не удалось подключиться к внешнему API.".to_owned()
|
||||
}
|
||||
RuntimeError::RestAdapter(_) => "Не удалось выполнить запрос к внешнему API.".to_owned(),
|
||||
RuntimeError::ProtocolAdapter(_) => "Не удалось выполнить протокольный адаптер.".to_owned(),
|
||||
RuntimeError::UnsupportedProtocol { .. } => {
|
||||
"Протокол операции не поддерживается.".to_owned()
|
||||
}
|
||||
RuntimeError::ConcurrencyLimitExceeded { .. } => {
|
||||
"Сервис временно перегружен и не может выполнить инструмент.".to_owned()
|
||||
}
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => {
|
||||
"Режим выполнения операции не поддерживается.".to_owned()
|
||||
}
|
||||
RuntimeError::InvalidPreparedRequest { .. } => {
|
||||
"Не удалось подготовить корректный API-запрос.".to_owned()
|
||||
}
|
||||
RuntimeError::ConfirmationRequired {
|
||||
confirmation_token,
|
||||
expires_in_ms,
|
||||
..
|
||||
} => format!(
|
||||
"Операция требует подтверждения. Повторите вызов с _crank_confirmation_token=\"{}\" в течение {} секунд.",
|
||||
confirmation_token,
|
||||
fn confirmation_message(
|
||||
base_message: &str,
|
||||
token: &str,
|
||||
expires_in_ms: u64,
|
||||
locale: ExecutionLocale,
|
||||
) -> String {
|
||||
match locale {
|
||||
ExecutionLocale::Ru => format!(
|
||||
"{base_message} Повторите вызов с _crank_confirmation_token=\"{token}\" в течение {} секунд.",
|
||||
expires_in_ms / 1000
|
||||
),
|
||||
ExecutionLocale::En => format!(
|
||||
"{base_message} Repeat the call with _crank_confirmation_token=\"{token}\" within {} seconds.",
|
||||
expires_in_ms / 1000
|
||||
),
|
||||
RuntimeError::InvalidConfirmationToken { .. } => {
|
||||
"Токен подтверждения недействителен, истек или уже был использован.".to_owned()
|
||||
}
|
||||
RuntimeError::ConfirmationStoreUnavailable { .. } => {
|
||||
"Хранилище подтверждений временно недоступно.".to_owned()
|
||||
}
|
||||
RuntimeError::IdempotencyStoreUnavailable { .. } => {
|
||||
"Хранилище идемпотентности временно недоступно.".to_owned()
|
||||
}
|
||||
RuntimeError::IdempotencyInProgress { .. } => {
|
||||
"Операция с этим ключом идемпотентности уже выполняется.".to_owned()
|
||||
}
|
||||
RuntimeError::IdempotencyConflict { .. } => {
|
||||
"Ключ идемпотентности уже использован с другими параметрами.".to_owned()
|
||||
}
|
||||
RuntimeError::IdempotencyOutcomeUnknown { .. } => {
|
||||
"Результат предыдущего выполнения неизвестен; автоматический повтор заблокирован."
|
||||
.to_owned()
|
||||
}
|
||||
RuntimeError::MissingAuthProfile { .. } => "Профиль авторизации не найден.".to_owned(),
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"Секрет авторизации не найден.".to_owned()
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => {
|
||||
"Секрет авторизации имеет неподходящий формат.".to_owned()
|
||||
}
|
||||
RuntimeError::SecretCrypto { .. } => {
|
||||
"Не удалось расшифровать секрет авторизации.".to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_recoverable_runtime_error(error: &RuntimeError) -> bool {
|
||||
matches!(
|
||||
error,
|
||||
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus {
|
||||
status: 408 | 429 | 500..=599,
|
||||
..
|
||||
}) | RuntimeError::RestAdapter(RestAdapterError::Transport(_))
|
||||
| RuntimeError::ConcurrencyLimitExceeded { .. }
|
||||
| RuntimeError::SecretCrypto { .. }
|
||||
| RuntimeError::ConfirmationRequired { .. }
|
||||
| RuntimeError::IdempotencyStoreUnavailable { .. }
|
||||
| RuntimeError::IdempotencyInProgress { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn suggested_action(error: &RuntimeError) -> Option<&'static str> {
|
||||
match error {
|
||||
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus {
|
||||
status: 401 | 403, ..
|
||||
}) => Some("Проверьте настройки авторизации внешнего API."),
|
||||
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus { status: 404, .. }) => {
|
||||
Some("Проверьте путь endpoint-а и параметры запроса.")
|
||||
const fn suggested_action(
|
||||
retryability: Retryability,
|
||||
locale: ExecutionLocale,
|
||||
) -> Option<&'static str> {
|
||||
match (retryability, locale) {
|
||||
(Retryability::AfterDelay | Retryability::Safe, ExecutionLocale::Ru) => {
|
||||
Some("Повторите запрос позже.")
|
||||
}
|
||||
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus {
|
||||
status: 408 | 429 | 500..=599,
|
||||
..
|
||||
})
|
||||
| RuntimeError::RestAdapter(RestAdapterError::Transport(_))
|
||||
| RuntimeError::ConcurrencyLimitExceeded { .. } => Some("Повторите запрос позже."),
|
||||
RuntimeError::Schema(_)
|
||||
| RuntimeError::Mapping(_)
|
||||
| RuntimeError::InvalidPreparedRequest { .. } => {
|
||||
Some("Проверьте параметры вызова инструмента.")
|
||||
(Retryability::AfterDelay | Retryability::Safe, ExecutionLocale::En) => {
|
||||
Some("Retry the request later.")
|
||||
}
|
||||
RuntimeError::ConfirmationRequired { .. } => {
|
||||
Some("Повторите вызов с указанным токеном подтверждения.")
|
||||
}
|
||||
RuntimeError::InvalidConfirmationToken { .. } => {
|
||||
Some("Запросите новый токен подтверждения.")
|
||||
}
|
||||
RuntimeError::ConfirmationStoreUnavailable { .. } => Some("Повторите запрос позже."),
|
||||
RuntimeError::IdempotencyStoreUnavailable { .. }
|
||||
| RuntimeError::IdempotencyInProgress { .. } => Some("Повторите запрос позже."),
|
||||
RuntimeError::IdempotencyConflict { .. } => {
|
||||
Some("Используйте новый ключ идемпотентности для изменённого запроса.")
|
||||
}
|
||||
RuntimeError::IdempotencyOutcomeUnknown { .. } => {
|
||||
(Retryability::ManualReconcile, ExecutionLocale::Ru) => {
|
||||
Some("Проверьте результат во внешней системе перед ручным повтором.")
|
||||
}
|
||||
RuntimeError::MissingAuthProfile { .. }
|
||||
| RuntimeError::MissingSecret { .. }
|
||||
| RuntimeError::MissingSecretVersion { .. }
|
||||
| RuntimeError::InvalidAuthSecretValue { .. }
|
||||
| RuntimeError::SecretCrypto { .. } => {
|
||||
Some("Проверьте настройки авторизации и секретов в Crank.")
|
||||
(Retryability::ManualReconcile, ExecutionLocale::En) => {
|
||||
Some("Check the result in the external system before retrying manually.")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn upstream_status(error: &RuntimeError) -> Option<u16> {
|
||||
match error {
|
||||
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus { status, .. }) => {
|
||||
Some(*status)
|
||||
}
|
||||
_ => None,
|
||||
(Retryability::RequiresConfirmation, ExecutionLocale::Ru) => {
|
||||
Some("Повторите вызов с указанным токеном подтверждения.")
|
||||
}
|
||||
(Retryability::RequiresConfirmation, ExecutionLocale::En) => {
|
||||
Some("Repeat the call with the provided confirmation token.")
|
||||
}
|
||||
(Retryability::Never, _) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
jsonrpc::{jsonrpc_error, jsonrpc_result, request_id},
|
||||
manifest::{CALL_TOOL_NAME, SEARCH_TOOLS_NAME, searchable_tools},
|
||||
session::SessionState,
|
||||
tool_error::generic_tool_error_contract,
|
||||
tool_error::{McpControlError, control_tool_error_contract, execution_locale},
|
||||
transport::{ResponseMode, transport_response},
|
||||
};
|
||||
|
||||
@@ -84,18 +84,11 @@ pub(super) async fn handle_catalog_tool_call(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
generic_tool_error_contract(
|
||||
"catalog_revision_changed",
|
||||
format!(
|
||||
"catalog revision {} is no longer current",
|
||||
proxy.catalog_revision
|
||||
),
|
||||
control_tool_error_contract(
|
||||
McpControlError::CatalogRevisionChanged,
|
||||
transport_request_id,
|
||||
transport_correlation.trace_id().as_str(),
|
||||
true,
|
||||
Some(
|
||||
"Повторите search_tools и вызовите инструмент с новой версией каталога.",
|
||||
),
|
||||
execution_locale(message),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -253,7 +246,7 @@ fn handle_search_tools(
|
||||
}
|
||||
|
||||
fn catalog_revision(catalog: &PublishedAgentCatalog) -> String {
|
||||
format!("agent-version-{}", catalog.agent_version)
|
||||
catalog.catalog_revision.clone()
|
||||
}
|
||||
|
||||
fn invalid_arguments_response(
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use crank_community_mcp::manifest::{analyze_published_tool_catalog, tool_definitions};
|
||||
use crank_community_mcp::manifest::{
|
||||
analyze_published_tool_catalog, catalog_tool_definitions, tool_definitions,
|
||||
};
|
||||
use crank_core::{
|
||||
ExecutionConfig, HttpMethod, Operation, OperationId, OperationSecurityLevel, OperationStatus,
|
||||
Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
|
||||
Protocol, RestTarget, Target, ToolAccessMode, ToolDescription, ToolSelectionPolicy,
|
||||
WorkspaceId,
|
||||
};
|
||||
use crank_mapping::MappingSet;
|
||||
use crank_registry::{PublishedAgentTool, RegistryOperation};
|
||||
use crank_registry::{PublishedAgentCatalog, PublishedAgentTool, RegistryOperation};
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
@@ -71,6 +75,57 @@ fn catalog_budget_uses_the_same_definitions_as_tools_list() {
|
||||
assert!(!analysis.budget.exceeds_recommended_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_catalog_discovery_profile_handles_200_agents_2000_operations_under_budget() {
|
||||
const AGENT_COUNT: usize = 200;
|
||||
const TOOLS_PER_AGENT: usize = 10;
|
||||
const P95_BUDGET_MS: u128 = 150;
|
||||
|
||||
let mut durations = Vec::with_capacity(AGENT_COUNT);
|
||||
let mut total_definitions = 0usize;
|
||||
let started = Instant::now();
|
||||
|
||||
for agent_index in 0..AGENT_COUNT {
|
||||
let tools = (0..TOOLS_PER_AGENT)
|
||||
.map(|tool_index| {
|
||||
let mut tool = published_tool();
|
||||
tool.agent_id = format!("agent_{agent_index:03}").into();
|
||||
tool.agent_slug = format!("agent-{agent_index:03}");
|
||||
tool.operation.id =
|
||||
OperationId::new(format!("op_{agent_index:03}_{tool_index:03}"));
|
||||
tool.tool_name = format!("tool_{agent_index:03}_{tool_index:03}");
|
||||
tool.tool_title = format!("Tool {agent_index:03}-{tool_index:03}");
|
||||
tool
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let catalog = PublishedAgentCatalog {
|
||||
agent_version: 1,
|
||||
catalog_revision: format!("agent-{agent_index:03}-revision-1"),
|
||||
tool_selection_policy: ToolSelectionPolicy {
|
||||
mode: ToolAccessMode::Direct,
|
||||
..Default::default()
|
||||
},
|
||||
tools,
|
||||
};
|
||||
|
||||
let catalog_started = Instant::now();
|
||||
let definitions = catalog_tool_definitions(&catalog);
|
||||
durations.push(catalog_started.elapsed());
|
||||
total_definitions += definitions.len();
|
||||
}
|
||||
|
||||
durations.sort_unstable();
|
||||
let p95 = durations[((durations.len() * 95).div_ceil(100)).saturating_sub(1)];
|
||||
|
||||
assert_eq!(total_definitions, AGENT_COUNT * TOOLS_PER_AGENT);
|
||||
assert!(
|
||||
p95.as_millis() <= P95_BUDGET_MS,
|
||||
"agent catalog discovery p95={}ms total={}ms",
|
||||
p95.as_millis(),
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
|
||||
fn published_tool() -> PublishedAgentTool {
|
||||
PublishedAgentTool {
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
use crank_adapter_rest::RestAdapterError;
|
||||
use crank_community_mcp::tool_error::tool_error_contract_from_runtime;
|
||||
use crank_community_mcp::tool_error::{
|
||||
tool_error_contract_from_failure, tool_error_contract_from_runtime,
|
||||
};
|
||||
use crank_core::{
|
||||
CorrelationContext, ExecutionErrorCode, ExecutionFailure, ExecutionLocale,
|
||||
ProtocolAdapterError, Retryability,
|
||||
};
|
||||
use crank_runtime::RuntimeError;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn maps_upstream_429_to_recoverable_structured_tool_error() {
|
||||
let contract = tool_error_contract_from_runtime(
|
||||
&RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus {
|
||||
&RuntimeError::ProtocolAdapter(ProtocolAdapterError::UnexpectedStatus {
|
||||
status: 429,
|
||||
body: json!({
|
||||
"error": "rate limit exceeded",
|
||||
"internal_trace": "do not leak"
|
||||
}),
|
||||
dispatch: crank_core::DispatchEvidence::MayHaveDispatched,
|
||||
}),
|
||||
"req-429",
|
||||
"0af7651916cd43dd8448eb211c80319c",
|
||||
ExecutionLocale::Ru,
|
||||
);
|
||||
|
||||
assert_eq!(contract.error_code, "upstream_rate_limited");
|
||||
assert_eq!(contract.stage, "upstream");
|
||||
assert_eq!(contract.retryability, "after_delay");
|
||||
assert_eq!(contract.outcome_certainty, "certain");
|
||||
assert!(contract.recoverable);
|
||||
assert_eq!(contract.upstream_status, Some(429));
|
||||
assert_eq!(contract.request_id, "req-429");
|
||||
@@ -26,6 +31,22 @@ fn maps_upstream_429_to_recoverable_structured_tool_error() {
|
||||
assert!(!contract.message.contains("internal_trace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_upstream_429_to_english_suggested_action() {
|
||||
let contract = tool_error_contract_from_runtime(
|
||||
&RuntimeError::ProtocolAdapter(ProtocolAdapterError::UnexpectedStatus {
|
||||
status: 429,
|
||||
dispatch: crank_core::DispatchEvidence::MayHaveDispatched,
|
||||
}),
|
||||
"req-429",
|
||||
"0af7651916cd43dd8448eb211c80319c",
|
||||
ExecutionLocale::En,
|
||||
);
|
||||
|
||||
assert_eq!(contract.error_code, "upstream_rate_limited");
|
||||
assert_eq!(contract.suggested_action, Some("Retry the request later."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_mapping_error_to_non_recoverable_structured_tool_error() {
|
||||
let contract = tool_error_contract_from_runtime(
|
||||
@@ -35,15 +56,91 @@ fn maps_mapping_error_to_non_recoverable_structured_tool_error() {
|
||||
},
|
||||
"req-map",
|
||||
"0af7651916cd43dd8448eb211c80319c",
|
||||
ExecutionLocale::Ru,
|
||||
);
|
||||
|
||||
assert_eq!(contract.error_code, "runtime_error");
|
||||
assert_eq!(contract.error_code, "prepared_request_invalid");
|
||||
assert_eq!(contract.stage, "request_preparation");
|
||||
assert_eq!(contract.retryability, "never");
|
||||
assert!(!contract.recoverable);
|
||||
assert_eq!(contract.upstream_status, None);
|
||||
assert_eq!(contract.request_id, "req-map");
|
||||
assert_eq!(contract.trace_id, "0af7651916cd43dd8448eb211c80319c");
|
||||
assert_eq!(contract.suggested_action, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_timeout_requires_manual_reconciliation() {
|
||||
let contract = tool_error_contract_from_runtime(
|
||||
&RuntimeError::ExecutionDeadlineElapsed {
|
||||
may_have_dispatched: true,
|
||||
},
|
||||
"req-timeout",
|
||||
"0af7651916cd43dd8448eb211c80319c",
|
||||
ExecutionLocale::Ru,
|
||||
);
|
||||
assert_eq!(contract.error_code, "upstream_timeout");
|
||||
assert_eq!(contract.retryability, "manual_reconcile");
|
||||
assert_eq!(contract.outcome_certainty, "outcome_unknown");
|
||||
assert!(!contract.recoverable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_execution_code_projects_the_same_canonical_semantics_as_admin() {
|
||||
for code in ExecutionErrorCode::ALL {
|
||||
let contract = tool_error_contract_from_failure(
|
||||
&ExecutionFailure::new(code, CorrelationContext::generate()),
|
||||
ExecutionLocale::En,
|
||||
);
|
||||
assert_eq!(contract.error_code, code.as_str());
|
||||
assert_eq!(contract.stage, code.stage().as_str());
|
||||
assert_eq!(contract.retryability, code.retryability().as_str());
|
||||
assert_eq!(
|
||||
contract.outcome_certainty,
|
||||
code.outcome_certainty().as_str()
|
||||
);
|
||||
assert_eq!(
|
||||
contract.recoverable,
|
||||
matches!(
|
||||
code.retryability(),
|
||||
Retryability::Safe | Retryability::AfterDelay | Retryability::RequiresConfirmation
|
||||
)
|
||||
);
|
||||
assert!(!contract.message.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_challenge_is_localized_for_mcp_clients() {
|
||||
let failure = ExecutionFailure::new(
|
||||
ExecutionErrorCode::ConfirmationRequired,
|
||||
CorrelationContext::generate(),
|
||||
)
|
||||
.try_with_confirmation("ct_safe", 30_000)
|
||||
.expect("valid confirmation challenge");
|
||||
|
||||
let en = tool_error_contract_from_failure(&failure, ExecutionLocale::En);
|
||||
assert!(
|
||||
en.message.contains(
|
||||
"Repeat the call with _crank_confirmation_token=\"ct_safe\" within 30 seconds."
|
||||
),
|
||||
"{:?}",
|
||||
en.message
|
||||
);
|
||||
assert_eq!(
|
||||
contract.suggested_action,
|
||||
Some("Проверьте параметры вызова инструмента.")
|
||||
en.suggested_action,
|
||||
Some("Repeat the call with the provided confirmation token.")
|
||||
);
|
||||
|
||||
let ru = tool_error_contract_from_failure(&failure, ExecutionLocale::Ru);
|
||||
assert!(
|
||||
ru.message
|
||||
.contains("Повторите вызов с _crank_confirmation_token=\"ct_safe\""),
|
||||
"{:?}",
|
||||
ru.message
|
||||
);
|
||||
assert_eq!(
|
||||
ru.suggested_action,
|
||||
Some("Повторите вызов с указанным токеном подтверждения.")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ impl fmt::Debug for OutboundSettings {
|
||||
f.debug_struct("OutboundSettings")
|
||||
.field("allowed_host_count", &self.allowed_hosts.len())
|
||||
.field("denied_host_count", &self.denied_hosts.len())
|
||||
.field("max_request_bytes", &self.max_request_bytes)
|
||||
.field("max_response_bytes", &self.max_response_bytes)
|
||||
.finish()
|
||||
}
|
||||
@@ -96,7 +97,10 @@ impl fmt::Debug for AdminProcessConfig {
|
||||
.field("storage_root", &"configured")
|
||||
.field("session_secret", &self.session_secret)
|
||||
.field("password_pepper", &self.password_pepper)
|
||||
.field("bootstrap_password", &self.bootstrap_password)
|
||||
.field(
|
||||
"bootstrap_password",
|
||||
&self.bootstrap_password.as_ref().map(|_| "configured"),
|
||||
)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
use std::{collections::BTreeMap, fmt, net::SocketAddr, path::PathBuf};
|
||||
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
ConfigError, ConfigSource, Diagnostic, DiagnosticCode, ProcessScope, SecretString,
|
||||
deployment_field_registry, field_registry,
|
||||
@@ -9,14 +5,20 @@ use crate::{
|
||||
schema::semantic_path_for,
|
||||
validation::{valid_database_host, valid_database_identifier, valid_percent_encoding},
|
||||
};
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fmt,
|
||||
net::{IpAddr, SocketAddr},
|
||||
path::PathBuf,
|
||||
};
|
||||
use url::Url;
|
||||
mod list_parsers;
|
||||
const MAX_ENV_VALUE_BYTES: usize = 8_192;
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ProcessKind {
|
||||
AdminApi,
|
||||
McpServer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct DeprecationRecord {
|
||||
pub field: &'static str,
|
||||
@@ -24,7 +26,6 @@ pub struct DeprecationRecord {
|
||||
pub replacement: &'static str,
|
||||
pub removal_window: &'static str,
|
||||
}
|
||||
|
||||
impl ProcessKind {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
@@ -49,7 +50,6 @@ pub struct PoolSettings {
|
||||
pub idle_timeout_ms: u64,
|
||||
pub max_lifetime_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DatabaseSettings {
|
||||
pub url: Option<SecretString>,
|
||||
@@ -60,27 +60,24 @@ pub struct DatabaseSettings {
|
||||
pub password: SecretString,
|
||||
pub pool: PoolSettings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CacheBackend {
|
||||
Memory,
|
||||
Valkey,
|
||||
Redis,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CacheSettings {
|
||||
pub backend: CacheBackend,
|
||||
pub url: Option<SecretString>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OutboundSettings {
|
||||
pub allowed_hosts: Vec<String>,
|
||||
pub denied_hosts: Vec<String>,
|
||||
pub max_request_bytes: usize,
|
||||
pub max_response_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RuntimeSettings {
|
||||
pub master_key: SecretString,
|
||||
@@ -90,20 +87,17 @@ pub struct RuntimeSettings {
|
||||
pub cache: CacheSettings,
|
||||
pub outbound: OutboundSettings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RateLimitSettings {
|
||||
pub requests_per_second: u32,
|
||||
pub burst: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MetricsSettings {
|
||||
pub enabled: bool,
|
||||
pub bind_addr: SocketAddr,
|
||||
pub bearer_token: Option<SecretString>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct OtlpSettings {
|
||||
pub endpoint: Option<String>,
|
||||
@@ -141,9 +135,9 @@ pub struct AdminProcessConfig {
|
||||
pub session_secret: SecretString,
|
||||
pub password_pepper: SecretString,
|
||||
pub session_ttl_hours: i64,
|
||||
pub trust_forwarded_headers: bool,
|
||||
pub trusted_proxy_ips: Vec<IpAddr>,
|
||||
pub bootstrap_email: String,
|
||||
pub bootstrap_password: SecretString,
|
||||
pub bootstrap_password: Option<SecretString>,
|
||||
pub bootstrap_display_name: String,
|
||||
pub demo_seed: bool,
|
||||
}
|
||||
@@ -313,7 +307,15 @@ impl<'a> Parser<'a> {
|
||||
}
|
||||
|
||||
fn secret(&mut self, name: &'static str, default: Option<&str>) -> SecretString {
|
||||
SecretString::new(self.string(name, default))
|
||||
let value = self.string(name, default);
|
||||
if let Some(spec) = field_registry().iter().find(|field| field.env_name == name)
|
||||
&& let Some(minimum) = spec.minimum
|
||||
&& !value.is_empty()
|
||||
&& value.len() < minimum as usize
|
||||
{
|
||||
self.push(DiagnosticCode::OutOfRange, spec.semantic_path);
|
||||
}
|
||||
SecretString::new(value)
|
||||
}
|
||||
|
||||
fn optional_secret(&mut self, name: &'static str) -> Option<SecretString> {
|
||||
@@ -501,41 +503,6 @@ impl<'a> Parser<'a> {
|
||||
Some(SecretString::new(raw))
|
||||
}
|
||||
}
|
||||
|
||||
fn host_list(&mut self, name: &'static str) -> Vec<String> {
|
||||
let Some(raw) = self.optional(name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if raw.len() > 16_384 {
|
||||
self.push(DiagnosticCode::OutOfRange, name);
|
||||
return Vec::new();
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
for item in raw.split(',') {
|
||||
let item = item.trim().to_ascii_lowercase();
|
||||
let base = item.strip_prefix("*.").unwrap_or(&item);
|
||||
let valid_ip = !item.starts_with("*.") && base.parse::<std::net::IpAddr>().is_ok();
|
||||
if item.is_empty()
|
||||
|| item.len() > 253
|
||||
|| item.contains('/')
|
||||
|| item.contains(char::is_whitespace)
|
||||
|| base.is_empty()
|
||||
|| (!valid_ip && base.contains(':'))
|
||||
|| (item.starts_with("*.") && base.parse::<std::net::IpAddr>().is_ok())
|
||||
{
|
||||
self.push(DiagnosticCode::InvalidType, name);
|
||||
continue;
|
||||
}
|
||||
if !items.contains(&item) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
if items.len() > 256 {
|
||||
self.push(DiagnosticCode::OutOfRange, name);
|
||||
items.truncate(256);
|
||||
}
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_database(parser: &mut Parser<'_>) -> DatabaseSettings {
|
||||
@@ -634,7 +601,6 @@ pub(crate) fn parse_database_source(
|
||||
}
|
||||
Ok((database, parser.deprecations))
|
||||
}
|
||||
|
||||
pub fn parse_process(
|
||||
kind: ProcessKind,
|
||||
source: ConfigSource,
|
||||
@@ -642,7 +608,6 @@ pub fn parse_process(
|
||||
let values = source.values();
|
||||
let mut parser = Parser::new(kind, values);
|
||||
parser.check_source();
|
||||
|
||||
let database = parse_database(&mut parser);
|
||||
|
||||
let cache_backend = match parser
|
||||
@@ -709,6 +674,7 @@ pub fn parse_process(
|
||||
outbound: OutboundSettings {
|
||||
allowed_hosts: parser.host_list("CRANK_OUTBOUND_ALLOWED_HOSTS"),
|
||||
denied_hosts: parser.host_list("CRANK_OUTBOUND_DENIED_HOSTS"),
|
||||
max_request_bytes: parser.number("CRANK_OUTBOUND_MAX_REQUEST_BYTES") as usize,
|
||||
max_response_bytes: parser.number("CRANK_OUTBOUND_MAX_RESPONSE_BYTES") as usize,
|
||||
},
|
||||
};
|
||||
@@ -811,14 +777,24 @@ pub fn parse_process(
|
||||
ProcessKind::AdminApi => {
|
||||
let rps = parser.number("CRANK_ADMIN_RATE_LIMIT_RPS") as u32;
|
||||
let burst = parser.number("CRANK_ADMIN_RATE_LIMIT_BURST") as u32;
|
||||
let bind_addr = parser.socket("CRANK_ADMIN_BIND");
|
||||
if burst < rps {
|
||||
parser.push(DiagnosticCode::UnsafeCombination, "admin.rate_limit.burst");
|
||||
}
|
||||
if observability.environment == "production"
|
||||
&& !bind_addr.ip().is_loopback()
|
||||
&& !runtime
|
||||
.base_url
|
||||
.as_deref()
|
||||
.is_some_and(|url| url.starts_with("https://"))
|
||||
{
|
||||
parser.push(DiagnosticCode::UnsafeCombination, "admin.exposure.tls");
|
||||
}
|
||||
Projection::Admin(AdminProcessConfig {
|
||||
database,
|
||||
runtime,
|
||||
observability,
|
||||
bind_addr: parser.socket("CRANK_ADMIN_BIND"),
|
||||
bind_addr,
|
||||
storage_root: parser.absolute_path("CRANK_STORAGE_ROOT", "/var/lib/crank/storage"),
|
||||
rate_limit: RateLimitSettings {
|
||||
requests_per_second: rps,
|
||||
@@ -829,9 +805,9 @@ pub fn parse_process(
|
||||
session_secret: parser.secret("CRANK_SESSION_SECRET", None),
|
||||
password_pepper: parser.secret("CRANK_PASSWORD_PEPPER", None),
|
||||
session_ttl_hours: parser.number("CRANK_SESSION_TTL_HOURS") as i64,
|
||||
trust_forwarded_headers: parser.boolean("CRANK_TRUST_FORWARDED_HEADERS"),
|
||||
trusted_proxy_ips: parser.ip_list("CRANK_TRUSTED_PROXY_IPS"),
|
||||
bootstrap_email: parser.string("CRANK_BOOTSTRAP_ADMIN_EMAIL", None),
|
||||
bootstrap_password: parser.secret("CRANK_BOOTSTRAP_ADMIN_PASSWORD", None),
|
||||
bootstrap_password: parser.optional_secret("CRANK_BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
bootstrap_display_name: parser
|
||||
.string("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME", Some("Crank Owner")),
|
||||
demo_seed: parser.boolean("CRANK_DEMO_SEED"),
|
||||
@@ -889,14 +865,17 @@ fn fingerprint_parts(kind: ProcessKind, projection: &Projection) -> Vec<String>
|
||||
),
|
||||
format!("retention={}", config.invocation_log_retention_days),
|
||||
format!("session_ttl={}", config.session_ttl_hours),
|
||||
format!("trusted={}", config.trust_forwarded_headers),
|
||||
format!("trusted_proxies={:?}", config.trusted_proxy_ips),
|
||||
format!("demo={}", config.demo_seed),
|
||||
"storage=path-configured".to_owned(),
|
||||
format!("session_secret={}", config.session_secret.is_configured()),
|
||||
format!("pepper={}", config.password_pepper.is_configured()),
|
||||
format!(
|
||||
"bootstrap_password={}",
|
||||
config.bootstrap_password.is_configured()
|
||||
config
|
||||
.bootstrap_password
|
||||
.as_ref()
|
||||
.is_some_and(SecretString::is_configured)
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -970,6 +949,7 @@ fn fingerprint_parts(kind: ProcessKind, projection: &Projection) -> Vec<String>
|
||||
),
|
||||
format!("allowed={}", allowed.join(",")),
|
||||
format!("denied={}", denied.join(",")),
|
||||
format!("max_request={}", runtime.outbound.max_request_bytes),
|
||||
format!("max_response={}", runtime.outbound.max_response_bytes),
|
||||
format!("environment={}", observability.environment),
|
||||
format!("log_filter={}", observability.log_filter),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::{
|
||||
DiagnosticCode,
|
||||
validation::{parse_host_list, parse_ip_list},
|
||||
};
|
||||
|
||||
impl super::Parser<'_> {
|
||||
pub(super) fn host_list(&mut self, name: &'static str) -> Vec<String> {
|
||||
let Some(raw) = self.optional(name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let parsed = parse_host_list(&raw);
|
||||
if parsed.invalid {
|
||||
self.push(DiagnosticCode::InvalidType, name);
|
||||
}
|
||||
if parsed.out_of_range {
|
||||
self.push(DiagnosticCode::OutOfRange, name);
|
||||
}
|
||||
parsed.items
|
||||
}
|
||||
|
||||
pub(super) fn ip_list(&mut self, name: &'static str) -> Vec<IpAddr> {
|
||||
let Some(raw) = self.optional(name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let parsed = parse_ip_list(&raw);
|
||||
if parsed.invalid {
|
||||
self.push(DiagnosticCode::InvalidType, name);
|
||||
}
|
||||
if parsed.out_of_range {
|
||||
self.push(DiagnosticCode::OutOfRange, name);
|
||||
}
|
||||
parsed.items
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ pub fn reference_section() -> String {
|
||||
};
|
||||
let bounds = match (field.minimum, field.maximum) {
|
||||
(Some(minimum), Some(maximum)) => format!("{minimum}..={maximum}"),
|
||||
(Some(minimum), None) => format!(">={minimum}"),
|
||||
(None, Some(maximum)) => format!("<={maximum}"),
|
||||
_ => "-".to_owned(),
|
||||
};
|
||||
output.push_str(&format!(
|
||||
@@ -102,9 +104,10 @@ fn example_value(field: &FieldSpec, production: bool) -> String {
|
||||
}
|
||||
match (field.env_name, production) {
|
||||
("CRANK_ENVIRONMENT", true) => "production".to_owned(),
|
||||
("CRANK_BASE_URL", _) => "http://localhost:3000".to_owned(),
|
||||
("CRANK_BASE_URL", true) => "https://crank.example.local".to_owned(),
|
||||
("CRANK_BASE_URL", false) => "http://localhost:3000".to_owned(),
|
||||
("POSTGRES_HOST", true) => "postgres".to_owned(),
|
||||
("CRANK_TRUST_FORWARDED_HEADERS", true) => "true".to_owned(),
|
||||
("CRANK_TRUSTED_PROXY_IPS", true) => "127.0.0.1".to_owned(),
|
||||
_ => field.default.unwrap_or("").to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ macro_rules! f {
|
||||
};
|
||||
}
|
||||
|
||||
static FIELDS: [FieldSpec; 57] = [
|
||||
static FIELDS: [FieldSpec; 59] = [
|
||||
FieldSpec {
|
||||
compatibility: Some("legacy URL form"),
|
||||
rules: &[
|
||||
@@ -222,7 +222,7 @@ static FIELDS: [FieldSpec; 57] = [
|
||||
"secret",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(32),
|
||||
None,
|
||||
Secret
|
||||
)
|
||||
@@ -317,6 +317,17 @@ static FIELDS: [FieldSpec; 57] = [
|
||||
Internal
|
||||
)
|
||||
},
|
||||
f!(
|
||||
"outbound.max_request_bytes",
|
||||
"CRANK_OUTBOUND_MAX_REQUEST_BYTES",
|
||||
Shared,
|
||||
"u64",
|
||||
Some("bytes"),
|
||||
Some("4194304"),
|
||||
Some(1),
|
||||
Some(67108864),
|
||||
Public
|
||||
),
|
||||
f!(
|
||||
"outbound.max_response_bytes",
|
||||
"CRANK_OUTBOUND_MAX_RESPONSE_BYTES",
|
||||
@@ -636,19 +647,37 @@ static FIELDS: [FieldSpec; 57] = [
|
||||
Public
|
||||
),
|
||||
FieldSpec {
|
||||
compatibility: Some("yes/no/on/off spellings are deprecated"),
|
||||
mode: FieldMode::DeprecatedNoEffect,
|
||||
compatibility: Some("deprecated boolean proxy trust; use CRANK_TRUSTED_PROXY_IPS"),
|
||||
..f!(
|
||||
"admin.trust_forwarded_headers",
|
||||
"CRANK_TRUST_FORWARDED_HEADERS",
|
||||
AdminApi,
|
||||
"bool",
|
||||
None,
|
||||
Some("false"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Public
|
||||
)
|
||||
},
|
||||
FieldSpec {
|
||||
rules: &[
|
||||
"only listed immediate peer IPs may supply X-Real-IP/X-Forwarded-For client identity",
|
||||
"empty value disables forwarded-header trust",
|
||||
],
|
||||
..f!(
|
||||
"admin.trusted_proxy_ips",
|
||||
"CRANK_TRUSTED_PROXY_IPS",
|
||||
AdminApi,
|
||||
"ip_list",
|
||||
None,
|
||||
Some(""),
|
||||
None,
|
||||
None,
|
||||
Internal
|
||||
)
|
||||
},
|
||||
FieldSpec {
|
||||
required: true,
|
||||
..f!(
|
||||
@@ -664,7 +693,7 @@ static FIELDS: [FieldSpec; 57] = [
|
||||
)
|
||||
},
|
||||
FieldSpec {
|
||||
required: true,
|
||||
compatibility: Some("deprecated startup-bootstrap password; use local bootstrap contract"),
|
||||
..f!(
|
||||
"admin.bootstrap.password",
|
||||
"CRANK_BOOTSTRAP_ADMIN_PASSWORD",
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
pub(crate) struct ParsedList<T> {
|
||||
pub(crate) items: Vec<T>,
|
||||
pub(crate) invalid: bool,
|
||||
pub(crate) out_of_range: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn valid_percent_encoding(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
let mut index = 0;
|
||||
@@ -40,3 +48,64 @@ pub(crate) fn valid_database_identifier(value: &str) -> bool {
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_host_list(raw: &str) -> ParsedList<String> {
|
||||
let mut result = ParsedList {
|
||||
items: Vec::new(),
|
||||
invalid: false,
|
||||
out_of_range: raw.len() > 16_384,
|
||||
};
|
||||
if result.out_of_range {
|
||||
return result;
|
||||
}
|
||||
for item in raw.split(',') {
|
||||
let item = item.trim().to_ascii_lowercase();
|
||||
let base = item.strip_prefix("*.").unwrap_or(&item);
|
||||
let valid_ip = !item.starts_with("*.") && base.parse::<IpAddr>().is_ok();
|
||||
if item.is_empty()
|
||||
|| item.len() > 253
|
||||
|| item.contains('/')
|
||||
|| item.contains(char::is_whitespace)
|
||||
|| base.is_empty()
|
||||
|| (!valid_ip && base.contains(':'))
|
||||
|| (item.starts_with("*.") && base.parse::<IpAddr>().is_ok())
|
||||
{
|
||||
result.invalid = true;
|
||||
continue;
|
||||
}
|
||||
if !result.items.contains(&item) {
|
||||
result.items.push(item);
|
||||
}
|
||||
}
|
||||
if result.items.len() > 256 {
|
||||
result.out_of_range = true;
|
||||
result.items.truncate(256);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn parse_ip_list(raw: &str) -> ParsedList<IpAddr> {
|
||||
let mut result = ParsedList {
|
||||
items: Vec::new(),
|
||||
invalid: false,
|
||||
out_of_range: raw.len() > 4096,
|
||||
};
|
||||
if result.out_of_range {
|
||||
return result;
|
||||
}
|
||||
for item in raw.split(',').map(str::trim) {
|
||||
match item.parse::<IpAddr>() {
|
||||
Ok(value) if !result.items.contains(&value) => result.items.push(value),
|
||||
Ok(_) => {}
|
||||
Err(_) => result.invalid = true,
|
||||
}
|
||||
}
|
||||
if result.items.is_empty() {
|
||||
result.invalid = true;
|
||||
}
|
||||
if result.items.len() > 64 {
|
||||
result.out_of_range = true;
|
||||
result.items.truncate(64);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ use crank_config::{
|
||||
parse_migrator, parse_process,
|
||||
};
|
||||
|
||||
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
|
||||
|
||||
fn required_admin() -> BTreeMap<String, String> {
|
||||
[
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
@@ -19,7 +21,7 @@ fn required_admin() -> BTreeMap<String, String> {
|
||||
}
|
||||
|
||||
fn required_mcp() -> BTreeMap<String, String> {
|
||||
[("CRANK_MASTER_KEY", "master")]
|
||||
[("CRANK_MASTER_KEY", TEST_MASTER_KEY)]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
||||
.collect()
|
||||
@@ -106,9 +108,9 @@ fn source_for(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_covers_exactly_the_57_observed_runtime_names() {
|
||||
fn registry_covers_exactly_the_59_observed_runtime_names() {
|
||||
let registry = field_registry();
|
||||
assert_eq!(registry.len(), 57);
|
||||
assert_eq!(registry.len(), 59);
|
||||
let unique = registry
|
||||
.iter()
|
||||
.map(|field| field.env_name)
|
||||
@@ -148,9 +150,10 @@ fn defaults_are_preserved_and_invalid_values_never_fall_back() {
|
||||
|
||||
for (name, value) in [
|
||||
("POSTGRES_PORT", "bad"),
|
||||
("CRANK_MASTER_KEY", "too-short"),
|
||||
("CRANK_SESSION_TTL_HOURS", "bad"),
|
||||
("CRANK_ADMIN_RATE_LIMIT_RPS", "bad"),
|
||||
("CRANK_TRUST_FORWARDED_HEADERS", "tru"),
|
||||
("CRANK_TRUSTED_PROXY_IPS", "not-an-ip"),
|
||||
] {
|
||||
let mut vars = required_admin();
|
||||
vars.insert(name.to_owned(), value.to_owned());
|
||||
@@ -309,6 +312,24 @@ fn process_specific_fields_and_zero_ports_fail_closed() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_admin_non_loopback_requires_https_base_url() {
|
||||
let mut vars = required_admin();
|
||||
vars.insert("CRANK_ENVIRONMENT".into(), "production".into());
|
||||
vars.insert("CRANK_ADMIN_BIND".into(), "0.0.0.0:3001".into());
|
||||
vars.insert("CRANK_BASE_URL".into(), "http://crank.example.test".into());
|
||||
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
||||
assert!(error.diagnostics().iter().any(|item| {
|
||||
item.code == DiagnosticCode::UnsafeCombination && item.field == "admin.exposure.tls"
|
||||
}));
|
||||
|
||||
let mut safe = required_admin();
|
||||
safe.insert("CRANK_ENVIRONMENT".into(), "production".into());
|
||||
safe.insert("CRANK_ADMIN_BIND".into(), "0.0.0.0:3001".into());
|
||||
safe.insert("CRANK_BASE_URL".into(), "https://crank.example.test".into());
|
||||
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(safe)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_secrets_and_consumer_invalid_values_fail_in_the_leaf_parser() {
|
||||
for name in [
|
||||
@@ -355,6 +376,7 @@ fn cross_field_and_typed_boundaries_fail_closed() {
|
||||
("POSTGRES_MAX_CONNECTIONS", "1025"),
|
||||
("CRANK_ADMIN_RATE_LIMIT_RPS", "100001"),
|
||||
("CRANK_ADMIN_RATE_LIMIT_BURST", "0"),
|
||||
("CRANK_OUTBOUND_MAX_REQUEST_BYTES", "67108865"),
|
||||
("CRANK_OUTBOUND_MAX_RESPONSE_BYTES", "67108865"),
|
||||
("OTEL_BSP_SCHEDULE_DELAY", "bad"),
|
||||
("OTEL_BSP_EXPORT_TIMEOUT", "300001"),
|
||||
@@ -406,11 +428,12 @@ fn inclusive_edges_and_legacy_boolean_spellings_are_explicit() {
|
||||
("POSTGRES_MAX_CONNECTIONS".into(), "1024".into()),
|
||||
("POSTGRES_MIN_CONNECTIONS".into(), "0".into()),
|
||||
("CRANK_RUNTIME_MAX_CONCURRENT_UNARY".into(), "1".into()),
|
||||
("CRANK_OUTBOUND_MAX_REQUEST_BYTES".into(), "67108864".into()),
|
||||
(
|
||||
"CRANK_OUTBOUND_MAX_RESPONSE_BYTES".into(),
|
||||
"67108864".into(),
|
||||
),
|
||||
("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into()),
|
||||
("CRANK_TRUSTED_PROXY_IPS".into(), "127.0.0.1,::1".into()),
|
||||
("CRANK_DEMO_SEED".into(), "off".into()),
|
||||
]);
|
||||
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
|
||||
@@ -419,7 +442,7 @@ fn inclusive_edges_and_legacy_boolean_spellings_are_explicit() {
|
||||
assert_eq!(admin.database.pool.max_connections, 1024);
|
||||
assert_eq!(admin.database.pool.min_connections, 0);
|
||||
assert_eq!(admin.runtime.max_concurrent_unary, 1);
|
||||
assert!(admin.trust_forwarded_headers);
|
||||
assert_eq!(admin.trusted_proxy_ips.len(), 2);
|
||||
assert!(!admin.demo_seed);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ fn generated_reference_distinguishes_required_and_optional_fields() {
|
||||
let reference = render::reference_section();
|
||||
|
||||
assert!(reference.contains(
|
||||
"| `CRANK_MASTER_KEY` | `runtime.master_key` | `Shared` | `secret/-` | `required/blank` |"
|
||||
"| `CRANK_MASTER_KEY` | `runtime.master_key` | `Shared` | `secret/-` | `required/blank` | `>=32` |"
|
||||
));
|
||||
assert!(
|
||||
reference
|
||||
|
||||
@@ -2,6 +2,10 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crank_config::{ConfigSource, ProcessKind, parse_process};
|
||||
|
||||
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
|
||||
const CANARY_ONE_MASTER_KEY: &str = "CANARY_ONE-000000000000000000000000000000000";
|
||||
const CANARY_TWO_MASTER_KEY: &str = "CANARY_TWO-000000000000000000000000000000000";
|
||||
|
||||
fn config(secret: &str) -> crank_config::EffectiveConfig {
|
||||
let vars = [
|
||||
("CRANK_MASTER_KEY", secret),
|
||||
@@ -18,8 +22,8 @@ fn config(secret: &str) -> crank_config::EffectiveConfig {
|
||||
|
||||
#[test]
|
||||
fn secrets_are_absent_from_debug_display_and_fingerprint() {
|
||||
let first = config("CANARY_ONE");
|
||||
let second = config("CANARY_TWO");
|
||||
let first = config(CANARY_ONE_MASTER_KEY);
|
||||
let second = config(CANARY_TWO_MASTER_KEY);
|
||||
let rendered = format!("{first:?}");
|
||||
assert!(!rendered.contains("CANARY_ONE"));
|
||||
assert_eq!(first.fingerprint(), second.fingerprint());
|
||||
@@ -35,18 +39,18 @@ fn secrets_are_absent_from_debug_display_and_fingerprint() {
|
||||
#[test]
|
||||
fn effective_semantics_not_input_spelling_drive_fingerprint() {
|
||||
let mut canonical = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
|
||||
("CRANK_TRUST_FORWARDED_HEADERS", "true"),
|
||||
("CRANK_DEMO_SEED", "true"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut compatibility = canonical.clone();
|
||||
compatibility.insert("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into());
|
||||
compatibility.insert("CRANK_DEMO_SEED".into(), "yes".into());
|
||||
|
||||
let canonical_config = parse_process(
|
||||
ProcessKind::AdminApi,
|
||||
@@ -71,7 +75,7 @@ fn effective_semantics_not_input_spelling_drive_fingerprint() {
|
||||
|
||||
#[test]
|
||||
fn diagnostics_are_bounded_json_and_never_echo_secret_canaries() {
|
||||
let canary = "CANARY_SECRET_VALUE";
|
||||
let canary = "CANARY_SECRET_VALUE-0000000000000000000000";
|
||||
let vars = [
|
||||
("CRANK_MASTER_KEY", canary),
|
||||
("CRANK_SESSION_SECRET", canary),
|
||||
@@ -111,7 +115,7 @@ fn diagnostics_are_bounded_json_and_never_echo_secret_canaries() {
|
||||
#[test]
|
||||
fn public_projection_debug_omits_urls_hosts_paths_and_identity_values() {
|
||||
let mut vars = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@CANARY.test"),
|
||||
@@ -135,7 +139,7 @@ fn public_projection_debug_omits_urls_hosts_paths_and_identity_values() {
|
||||
#[test]
|
||||
fn normalized_database_and_admin_default_urls_drive_fingerprint() {
|
||||
let base = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
@@ -160,7 +164,7 @@ fn normalized_database_and_admin_default_urls_drive_fingerprint() {
|
||||
assert_eq!(implicit.fingerprint(), url.fingerprint());
|
||||
|
||||
let tls = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum InvitationStatus {
|
||||
pub enum PlatformApiKeyStatus {
|
||||
Active,
|
||||
Revoked,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct ApprovalRequest {
|
||||
pub operation_version: u32,
|
||||
pub status: ApprovalRequestStatus,
|
||||
pub risk_level: OperationApprovalRiskLevel,
|
||||
pub request_id: Option<String>,
|
||||
pub trace_id: Option<String>,
|
||||
pub request_payload: Value,
|
||||
pub response_payload: Option<Value>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
|
||||
@@ -40,6 +40,15 @@ pub enum AuthConfig {
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub const fn kind(&self) -> AuthKind {
|
||||
match self {
|
||||
Self::Bearer(_) => AuthKind::Bearer,
|
||||
Self::Basic(_) => AuthKind::Basic,
|
||||
Self::ApiKeyHeader(_) => AuthKind::ApiKeyHeader,
|
||||
Self::ApiKeyQuery(_) => AuthKind::ApiKeyQuery,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secret_ids(&self) -> Vec<&SecretId> {
|
||||
match self {
|
||||
Self::Bearer(config) => vec![&config.secret_id],
|
||||
@@ -94,4 +103,16 @@ mod tests {
|
||||
assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z"));
|
||||
assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_reports_its_semantic_kind() {
|
||||
assert_eq!(
|
||||
AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig {
|
||||
header_name: "X-Api-Key".to_owned(),
|
||||
secret_id: SecretId::new("secret_01"),
|
||||
})
|
||||
.kind(),
|
||||
AuthKind::ApiKeyHeader
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{AgentId, CorrelationContext, OperationId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionOrigin {
|
||||
AdminDraft,
|
||||
AgentSnapshot,
|
||||
}
|
||||
|
||||
impl ExecutionOrigin {
|
||||
pub fn validate_agent(self, agent_id: Option<&AgentId>) -> Result<(), ExecutionOriginError> {
|
||||
match (self, agent_id) {
|
||||
(Self::AdminDraft, None) | (Self::AgentSnapshot, Some(_)) => Ok(()),
|
||||
(Self::AdminDraft, Some(_)) => Err(ExecutionOriginError::UnexpectedAgent),
|
||||
(Self::AgentSnapshot, None) => Err(ExecutionOriginError::MissingAgent),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ExecutionOriginError {
|
||||
#[error("admin draft execution cannot carry an agent identity")]
|
||||
UnexpectedAgent,
|
||||
#[error("agent snapshot execution requires an agent identity")]
|
||||
MissingAgent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionStage {
|
||||
Authorization,
|
||||
InputSchema,
|
||||
InputMapping,
|
||||
RequestPreparation,
|
||||
Admission,
|
||||
Adapter,
|
||||
Upstream,
|
||||
OutputMapping,
|
||||
OutputSchema,
|
||||
MandatoryPersistence,
|
||||
Runtime,
|
||||
}
|
||||
|
||||
impl ExecutionStage {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Authorization => "authorization",
|
||||
Self::InputSchema => "input_schema",
|
||||
Self::InputMapping => "input_mapping",
|
||||
Self::RequestPreparation => "request_preparation",
|
||||
Self::Admission => "admission",
|
||||
Self::Adapter => "adapter",
|
||||
Self::Upstream => "upstream",
|
||||
Self::OutputMapping => "output_mapping",
|
||||
Self::OutputSchema => "output_schema",
|
||||
Self::MandatoryPersistence => "mandatory_persistence",
|
||||
Self::Runtime => "runtime",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Retryability {
|
||||
Never,
|
||||
Safe,
|
||||
AfterDelay,
|
||||
ManualReconcile,
|
||||
RequiresConfirmation,
|
||||
}
|
||||
|
||||
impl Retryability {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Never => "never",
|
||||
Self::Safe => "safe",
|
||||
Self::AfterDelay => "after_delay",
|
||||
Self::ManualReconcile => "manual_reconcile",
|
||||
Self::RequiresConfirmation => "requires_confirmation",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OutcomeCertainty {
|
||||
Certain,
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
impl OutcomeCertainty {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Certain => "certain",
|
||||
Self::OutcomeUnknown => "outcome_unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionErrorCode {
|
||||
AuthorizationDenied,
|
||||
AuthProfileNotFound,
|
||||
SecretNotFound,
|
||||
SecretInvalid,
|
||||
InputSchemaInvalid,
|
||||
InputMappingInvalid,
|
||||
PreparedRequestInvalid,
|
||||
ExecutionOverloaded,
|
||||
SafetyStoreUnavailable,
|
||||
ProtocolUnsupported,
|
||||
ExecutionModeUnsupported,
|
||||
AdapterConfigurationInvalid,
|
||||
OutboundTargetRejected,
|
||||
UpstreamAuthError,
|
||||
UpstreamNotFound,
|
||||
UpstreamRateLimited,
|
||||
UpstreamServerError,
|
||||
UpstreamStatusError,
|
||||
UpstreamTimeout,
|
||||
UpstreamTransportError,
|
||||
UpstreamRequestTooLarge,
|
||||
UpstreamResponseTooLarge,
|
||||
OutputMappingInvalid,
|
||||
OutputSchemaInvalid,
|
||||
PersistenceUnavailable,
|
||||
RuntimeInternal,
|
||||
ConfirmationRequired,
|
||||
ConfirmationInvalid,
|
||||
IdempotencyInProgress,
|
||||
IdempotencyConflict,
|
||||
IdempotencyOutcomeUnknown,
|
||||
}
|
||||
|
||||
impl ExecutionErrorCode {
|
||||
pub const ALL: [Self; 31] = [
|
||||
Self::AuthorizationDenied,
|
||||
Self::AuthProfileNotFound,
|
||||
Self::SecretNotFound,
|
||||
Self::SecretInvalid,
|
||||
Self::InputSchemaInvalid,
|
||||
Self::InputMappingInvalid,
|
||||
Self::PreparedRequestInvalid,
|
||||
Self::ExecutionOverloaded,
|
||||
Self::SafetyStoreUnavailable,
|
||||
Self::ProtocolUnsupported,
|
||||
Self::ExecutionModeUnsupported,
|
||||
Self::AdapterConfigurationInvalid,
|
||||
Self::OutboundTargetRejected,
|
||||
Self::UpstreamAuthError,
|
||||
Self::UpstreamNotFound,
|
||||
Self::UpstreamRateLimited,
|
||||
Self::UpstreamServerError,
|
||||
Self::UpstreamStatusError,
|
||||
Self::UpstreamTimeout,
|
||||
Self::UpstreamTransportError,
|
||||
Self::UpstreamRequestTooLarge,
|
||||
Self::UpstreamResponseTooLarge,
|
||||
Self::OutputMappingInvalid,
|
||||
Self::OutputSchemaInvalid,
|
||||
Self::PersistenceUnavailable,
|
||||
Self::RuntimeInternal,
|
||||
Self::ConfirmationRequired,
|
||||
Self::ConfirmationInvalid,
|
||||
Self::IdempotencyInProgress,
|
||||
Self::IdempotencyConflict,
|
||||
Self::IdempotencyOutcomeUnknown,
|
||||
];
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationDenied => "authorization_denied",
|
||||
Self::AuthProfileNotFound => "auth_profile_not_found",
|
||||
Self::SecretNotFound => "secret_not_found",
|
||||
Self::SecretInvalid => "secret_invalid",
|
||||
Self::InputSchemaInvalid => "input_schema_invalid",
|
||||
Self::InputMappingInvalid => "input_mapping_invalid",
|
||||
Self::PreparedRequestInvalid => "prepared_request_invalid",
|
||||
Self::ExecutionOverloaded => "execution_overloaded",
|
||||
Self::SafetyStoreUnavailable => "safety_store_unavailable",
|
||||
Self::ProtocolUnsupported => "protocol_unsupported",
|
||||
Self::ExecutionModeUnsupported => "execution_mode_unsupported",
|
||||
Self::AdapterConfigurationInvalid => "adapter_configuration_invalid",
|
||||
Self::OutboundTargetRejected => "outbound_target_rejected",
|
||||
Self::UpstreamAuthError => "upstream_auth_error",
|
||||
Self::UpstreamNotFound => "upstream_not_found",
|
||||
Self::UpstreamRateLimited => "upstream_rate_limited",
|
||||
Self::UpstreamServerError => "upstream_server_error",
|
||||
Self::UpstreamStatusError => "upstream_status_error",
|
||||
Self::UpstreamTimeout => "upstream_timeout",
|
||||
Self::UpstreamTransportError => "upstream_transport_error",
|
||||
Self::UpstreamRequestTooLarge => "upstream_request_too_large",
|
||||
Self::UpstreamResponseTooLarge => "upstream_response_too_large",
|
||||
Self::OutputMappingInvalid => "output_mapping_invalid",
|
||||
Self::OutputSchemaInvalid => "output_schema_invalid",
|
||||
Self::PersistenceUnavailable => "persistence_unavailable",
|
||||
Self::RuntimeInternal => "runtime_internal",
|
||||
Self::ConfirmationRequired => "confirmation_required",
|
||||
Self::ConfirmationInvalid => "confirmation_invalid",
|
||||
Self::IdempotencyInProgress => "idempotency_in_progress",
|
||||
Self::IdempotencyConflict => "idempotency_conflict",
|
||||
Self::IdempotencyOutcomeUnknown => "idempotency_outcome_unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn stage(self) -> ExecutionStage {
|
||||
match self {
|
||||
Self::AuthorizationDenied
|
||||
| Self::AuthProfileNotFound
|
||||
| Self::SecretNotFound
|
||||
| Self::SecretInvalid => ExecutionStage::Authorization,
|
||||
Self::InputSchemaInvalid => ExecutionStage::InputSchema,
|
||||
Self::InputMappingInvalid => ExecutionStage::InputMapping,
|
||||
Self::PreparedRequestInvalid => ExecutionStage::RequestPreparation,
|
||||
Self::ExecutionOverloaded
|
||||
| Self::SafetyStoreUnavailable
|
||||
| Self::ConfirmationRequired
|
||||
| Self::ConfirmationInvalid
|
||||
| Self::IdempotencyInProgress
|
||||
| Self::IdempotencyConflict
|
||||
| Self::IdempotencyOutcomeUnknown => ExecutionStage::Admission,
|
||||
Self::ProtocolUnsupported
|
||||
| Self::ExecutionModeUnsupported
|
||||
| Self::AdapterConfigurationInvalid
|
||||
| Self::OutboundTargetRejected => ExecutionStage::Adapter,
|
||||
Self::UpstreamRequestTooLarge => ExecutionStage::RequestPreparation,
|
||||
Self::UpstreamAuthError
|
||||
| Self::UpstreamNotFound
|
||||
| Self::UpstreamRateLimited
|
||||
| Self::UpstreamServerError
|
||||
| Self::UpstreamStatusError
|
||||
| Self::UpstreamTimeout
|
||||
| Self::UpstreamTransportError
|
||||
| Self::UpstreamResponseTooLarge => ExecutionStage::Upstream,
|
||||
Self::OutputMappingInvalid => ExecutionStage::OutputMapping,
|
||||
Self::OutputSchemaInvalid => ExecutionStage::OutputSchema,
|
||||
Self::PersistenceUnavailable => ExecutionStage::MandatoryPersistence,
|
||||
Self::RuntimeInternal => ExecutionStage::Runtime,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn retryability(self) -> Retryability {
|
||||
match self {
|
||||
Self::ExecutionOverloaded
|
||||
| Self::SafetyStoreUnavailable
|
||||
| Self::UpstreamRateLimited
|
||||
| Self::UpstreamServerError
|
||||
| Self::UpstreamTimeout
|
||||
| Self::UpstreamTransportError
|
||||
| Self::PersistenceUnavailable
|
||||
| Self::IdempotencyInProgress => Retryability::AfterDelay,
|
||||
Self::ConfirmationRequired => Retryability::RequiresConfirmation,
|
||||
Self::IdempotencyOutcomeUnknown => Retryability::ManualReconcile,
|
||||
_ => Retryability::Never,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn outcome_certainty(self) -> OutcomeCertainty {
|
||||
match self {
|
||||
Self::IdempotencyOutcomeUnknown => OutcomeCertainty::OutcomeUnknown,
|
||||
_ => OutcomeCertainty::Certain,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn message(self, locale: ExecutionLocale) -> &'static str {
|
||||
match locale {
|
||||
ExecutionLocale::Ru => self.message_ru(),
|
||||
ExecutionLocale::En => self.message_en(),
|
||||
}
|
||||
}
|
||||
|
||||
const fn message_ru(self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationDenied => "Выполнение операции запрещено.",
|
||||
Self::AuthProfileNotFound => "Профиль авторизации не найден.",
|
||||
Self::SecretNotFound => "Секрет авторизации не найден.",
|
||||
Self::SecretInvalid => "Секрет авторизации имеет неподходящий формат.",
|
||||
Self::InputSchemaInvalid => "Входные параметры не прошли проверку схемы.",
|
||||
Self::InputMappingInvalid => "Не удалось сопоставить входные параметры.",
|
||||
Self::PreparedRequestInvalid => "Не удалось подготовить корректный API-запрос.",
|
||||
Self::ExecutionOverloaded => "Сервис временно перегружен.",
|
||||
Self::SafetyStoreUnavailable => "Обязательное хранилище безопасности недоступно.",
|
||||
Self::ProtocolUnsupported => "Протокол операции не поддерживается.",
|
||||
Self::ExecutionModeUnsupported => "Режим выполнения не поддерживается.",
|
||||
Self::AdapterConfigurationInvalid => "Конфигурация адаптера некорректна.",
|
||||
Self::OutboundTargetRejected => "Целевой адрес отклонён политикой безопасности.",
|
||||
Self::UpstreamAuthError => "Внешний API отклонил авторизацию.",
|
||||
Self::UpstreamNotFound => "Ресурс внешнего API не найден.",
|
||||
Self::UpstreamRateLimited => "Внешний API ограничил частоту запросов.",
|
||||
Self::UpstreamServerError => "Внешний API временно недоступен.",
|
||||
Self::UpstreamStatusError => "Внешний API вернул ошибочный статус.",
|
||||
Self::UpstreamTimeout => "Истекло время ожидания внешнего API.",
|
||||
Self::UpstreamTransportError => "Не удалось подключиться к внешнему API.",
|
||||
Self::UpstreamRequestTooLarge => "Запрос во внешний API превышает лимит.",
|
||||
Self::UpstreamResponseTooLarge => "Ответ внешнего API превышает лимит.",
|
||||
Self::OutputMappingInvalid => "Не удалось сопоставить ответ внешнего API.",
|
||||
Self::OutputSchemaInvalid => "Ответ не прошёл проверку схемы.",
|
||||
Self::PersistenceUnavailable => "Обязательное сохранение результата недоступно.",
|
||||
Self::RuntimeInternal => "Внутренняя ошибка выполнения.",
|
||||
Self::ConfirmationRequired => "Операция требует подтверждения.",
|
||||
Self::ConfirmationInvalid => "Подтверждение недействительно или истекло.",
|
||||
Self::IdempotencyInProgress => "Операция с этим ключом уже выполняется.",
|
||||
Self::IdempotencyConflict => "Ключ идемпотентности использован с другими параметрами.",
|
||||
Self::IdempotencyOutcomeUnknown => {
|
||||
"Результат предыдущего выполнения неизвестен; автоматический повтор запрещён."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn message_en(self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationDenied => "Operation execution is denied.",
|
||||
Self::AuthProfileNotFound => "Authorization profile was not found.",
|
||||
Self::SecretNotFound => "Authorization secret was not found.",
|
||||
Self::SecretInvalid => "Authorization secret has an invalid format.",
|
||||
Self::InputSchemaInvalid => "Input does not satisfy the operation schema.",
|
||||
Self::InputMappingInvalid => "Input parameters could not be mapped.",
|
||||
Self::PreparedRequestInvalid => "A valid upstream request could not be prepared.",
|
||||
Self::ExecutionOverloaded => "The service is temporarily overloaded.",
|
||||
Self::SafetyStoreUnavailable => "A mandatory safety store is unavailable.",
|
||||
Self::ProtocolUnsupported => "The operation protocol is unsupported.",
|
||||
Self::ExecutionModeUnsupported => "The execution mode is unsupported.",
|
||||
Self::AdapterConfigurationInvalid => "The adapter configuration is invalid.",
|
||||
Self::OutboundTargetRejected => "The target was rejected by the safety policy.",
|
||||
Self::UpstreamAuthError => "The upstream API rejected authorization.",
|
||||
Self::UpstreamNotFound => "The upstream resource was not found.",
|
||||
Self::UpstreamRateLimited => "The upstream API rate-limited the request.",
|
||||
Self::UpstreamServerError => "The upstream API is temporarily unavailable.",
|
||||
Self::UpstreamStatusError => "The upstream API returned an error status.",
|
||||
Self::UpstreamTimeout => "The upstream API timed out.",
|
||||
Self::UpstreamTransportError => "The upstream API could not be reached.",
|
||||
Self::UpstreamRequestTooLarge => "The upstream request exceeded its limit.",
|
||||
Self::UpstreamResponseTooLarge => "The upstream response exceeded its limit.",
|
||||
Self::OutputMappingInvalid => "The upstream response could not be mapped.",
|
||||
Self::OutputSchemaInvalid => "The output does not satisfy the operation schema.",
|
||||
Self::PersistenceUnavailable => "Mandatory result persistence is unavailable.",
|
||||
Self::RuntimeInternal => "Internal execution failure.",
|
||||
Self::ConfirmationRequired => "The operation requires confirmation.",
|
||||
Self::ConfirmationInvalid => "The confirmation is invalid or expired.",
|
||||
Self::IdempotencyInProgress => "The operation is already running for this key.",
|
||||
Self::IdempotencyConflict => "The idempotency key was used with different input.",
|
||||
Self::IdempotencyOutcomeUnknown => {
|
||||
"The previous outcome is unknown; automatic retry is unsafe."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExecutionLocale {
|
||||
Ru,
|
||||
En,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ConfirmationChallenge {
|
||||
token: String,
|
||||
expires_in_ms: u64,
|
||||
}
|
||||
|
||||
impl ConfirmationChallenge {
|
||||
fn try_new(
|
||||
token: impl Into<String>,
|
||||
expires_in_ms: u64,
|
||||
) -> Result<Self, ExecutionFailureBuildError> {
|
||||
let token = token.into();
|
||||
if token.is_empty() || token.len() > 4_096 || token.chars().any(char::is_control) {
|
||||
return Err(ExecutionFailureBuildError::InvalidConfirmation);
|
||||
}
|
||||
if !(1..=3_600_000).contains(&expires_in_ms) {
|
||||
return Err(ExecutionFailureBuildError::InvalidConfirmation);
|
||||
}
|
||||
Ok(Self {
|
||||
token,
|
||||
expires_in_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &str {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn expires_in_ms(&self) -> u64 {
|
||||
self.expires_in_ms
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ExecutionFailureBuildError {
|
||||
#[error("failure metadata is incompatible with the execution error code")]
|
||||
IncompatibleMetadata,
|
||||
#[error("confirmation metadata is invalid or exceeds its bound")]
|
||||
InvalidConfirmation,
|
||||
#[error("retry metadata is invalid or exceeds its bound")]
|
||||
InvalidRetryAfter,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ConfirmationChallenge {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ConfirmationChallenge")
|
||||
.field("token", &"[REDACTED]")
|
||||
.field("expires_in_ms", &self.expires_in_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExecutionFailure {
|
||||
code: ExecutionErrorCode,
|
||||
retryability: Retryability,
|
||||
outcome_certainty: OutcomeCertainty,
|
||||
correlation: CorrelationContext,
|
||||
upstream_status: Option<u16>,
|
||||
retry_after_ms: Option<u64>,
|
||||
confirmation: Option<ConfirmationChallenge>,
|
||||
}
|
||||
|
||||
impl ExecutionFailure {
|
||||
pub fn new(code: ExecutionErrorCode, correlation: CorrelationContext) -> Self {
|
||||
Self {
|
||||
code,
|
||||
retryability: code.retryability(),
|
||||
outcome_certainty: code.outcome_certainty(),
|
||||
correlation,
|
||||
upstream_status: None,
|
||||
retry_after_ms: None,
|
||||
confirmation: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_code(&self) -> ExecutionErrorCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn stage(&self) -> ExecutionStage {
|
||||
self.code.stage()
|
||||
}
|
||||
|
||||
pub fn retryability(&self) -> Retryability {
|
||||
self.retryability
|
||||
}
|
||||
|
||||
pub fn outcome_certainty(&self) -> OutcomeCertainty {
|
||||
self.outcome_certainty
|
||||
}
|
||||
|
||||
pub fn correlation(&self) -> &CorrelationContext {
|
||||
&self.correlation
|
||||
}
|
||||
|
||||
pub fn upstream_status(&self) -> Option<u16> {
|
||||
self.upstream_status
|
||||
}
|
||||
|
||||
pub fn retry_after_ms(&self) -> Option<u64> {
|
||||
self.retry_after_ms
|
||||
}
|
||||
|
||||
pub fn confirmation(&self) -> Option<&ConfirmationChallenge> {
|
||||
self.confirmation.as_ref()
|
||||
}
|
||||
|
||||
pub fn with_upstream_status(mut self, status: u16) -> Self {
|
||||
self.upstream_status = (100..=599).contains(&status).then_some(status);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn try_with_retry_after_ms(
|
||||
mut self,
|
||||
retry_after_ms: u64,
|
||||
) -> Result<Self, ExecutionFailureBuildError> {
|
||||
if self.retryability != Retryability::AfterDelay {
|
||||
return Err(ExecutionFailureBuildError::IncompatibleMetadata);
|
||||
}
|
||||
if !(1..=3_600_000).contains(&retry_after_ms) {
|
||||
return Err(ExecutionFailureBuildError::InvalidRetryAfter);
|
||||
}
|
||||
self.retry_after_ms = Some(retry_after_ms);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn try_with_confirmation(
|
||||
mut self,
|
||||
token: impl Into<String>,
|
||||
expires_in_ms: u64,
|
||||
) -> Result<Self, ExecutionFailureBuildError> {
|
||||
if self.code != ExecutionErrorCode::ConfirmationRequired {
|
||||
return Err(ExecutionFailureBuildError::IncompatibleMetadata);
|
||||
}
|
||||
self.confirmation = Some(ConfirmationChallenge::try_new(token, expires_in_ms)?);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn with_dispatch_uncertainty(mut self) -> Self {
|
||||
if matches!(
|
||||
self.code,
|
||||
ExecutionErrorCode::UpstreamTimeout
|
||||
| ExecutionErrorCode::UpstreamTransportError
|
||||
| ExecutionErrorCode::IdempotencyOutcomeUnknown
|
||||
| ExecutionErrorCode::PersistenceUnavailable
|
||||
) {
|
||||
self.retryability = Retryability::ManualReconcile;
|
||||
self.outcome_certainty = OutcomeCertainty::OutcomeUnknown;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ExecutionSuccess {
|
||||
pub operation_id: OperationId,
|
||||
pub operation_version: u32,
|
||||
pub origin: ExecutionOrigin,
|
||||
pub correlation: CorrelationContext,
|
||||
pub request_preview: Value,
|
||||
pub output: Value,
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
MachineAccessMode, Membership, OperationSecurityLevel, PlatformApiKeyScope, User, WorkspaceId,
|
||||
MachineAccessMode, Membership, OperationSecurityLevel, PlatformApiKeyId, PlatformApiKeyScope,
|
||||
User, WorkspaceId,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -10,6 +11,7 @@ pub struct VerifiedMachineCredential {
|
||||
pub machine_access_mode: MachineAccessMode,
|
||||
pub max_security_level: OperationSecurityLevel,
|
||||
pub scopes: Vec<PlatformApiKeyScope>,
|
||||
pub platform_api_key_id: Option<PlatformApiKeyId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -21,14 +27,26 @@ pub struct ResponseCacheScope {
|
||||
pub agent_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RuntimeRequestContext {
|
||||
pub request_id: RequestId,
|
||||
pub trace_context: TraceContext,
|
||||
pub response_cache_scope: Option<ResponseCacheScope>,
|
||||
pub metering_context: Option<MeteringContext>,
|
||||
dispatch_started: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl PartialEq for RuntimeRequestContext {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.request_id == other.request_id
|
||||
&& self.trace_context == other.trace_context
|
||||
&& self.response_cache_scope == other.response_cache_scope
|
||||
&& self.metering_context == other.metering_context
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for RuntimeRequestContext {}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MeteringContext {
|
||||
pub workspace_id: WorkspaceId,
|
||||
@@ -43,6 +61,7 @@ impl RuntimeRequestContext {
|
||||
trace_context,
|
||||
response_cache_scope: None,
|
||||
metering_context: None,
|
||||
dispatch_started: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,22 +130,43 @@ impl RuntimeRequestContext {
|
||||
pub fn metering_context(&self) -> Option<&MeteringContext> {
|
||||
self.metering_context.as_ref()
|
||||
}
|
||||
|
||||
pub fn with_dispatch_started(mut self, dispatch_started: Arc<AtomicBool>) -> Self {
|
||||
self.dispatch_started = Some(dispatch_started);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn mark_dispatch_started(&self) {
|
||||
if let Some(dispatch_started) = &self.dispatch_started {
|
||||
dispatch_started.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
pub struct PreparedRequest {
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub path_params: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub query_params: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trusted_header_names: BTreeSet<String>,
|
||||
pub body: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PreparedRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PreparedRequest")
|
||||
.field("path_param_count", &self.path_params.len())
|
||||
.field("query_param_count", &self.query_params.len())
|
||||
.field("header_count", &self.headers.len())
|
||||
.field("trusted_header_count", &self.trusted_header_names.len())
|
||||
.field("body_configured", &self.body.is_some())
|
||||
.field("timeout_ms", &self.timeout_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AdapterResponse {
|
||||
pub status_code: u16,
|
||||
@@ -143,8 +183,33 @@ pub enum ProtocolAdapterError {
|
||||
protocol: Protocol,
|
||||
mode: ExecutionMode,
|
||||
},
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
#[error("adapter configuration is invalid")]
|
||||
InvalidConfiguration,
|
||||
#[error("prepared request is invalid")]
|
||||
InvalidPreparedRequest,
|
||||
#[error("upstream request exceeded the configured limit")]
|
||||
RequestTooLarge,
|
||||
#[error("outbound target was rejected")]
|
||||
TargetRejected,
|
||||
#[error("upstream transport failed")]
|
||||
Transport { dispatch: DispatchEvidence },
|
||||
#[error("upstream request timed out")]
|
||||
Timeout { dispatch: DispatchEvidence },
|
||||
#[error("upstream response exceeded the configured limit")]
|
||||
ResponseTooLarge { dispatch: DispatchEvidence },
|
||||
#[error("upstream returned status {status}")]
|
||||
UnexpectedStatus {
|
||||
status: u16,
|
||||
dispatch: DispatchEvidence,
|
||||
},
|
||||
#[error("upstream response could not be decoded")]
|
||||
InvalidResponse { dispatch: DispatchEvidence },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DispatchEvidence {
|
||||
NotDispatched,
|
||||
MayHaveDispatched,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -55,6 +55,7 @@ define_id!(InvitationId);
|
||||
define_id!(PlatformApiKeyId);
|
||||
define_id!(ApprovalRequestId);
|
||||
define_id!(InvocationLogId);
|
||||
define_id!(ProductEventId);
|
||||
define_id!(AuditEventId);
|
||||
define_id!(SecretId);
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod correlation;
|
||||
pub mod edition;
|
||||
pub mod execution;
|
||||
pub mod ext;
|
||||
pub mod ids;
|
||||
pub mod observability;
|
||||
pub mod onboarding;
|
||||
pub mod operation;
|
||||
pub mod product_event;
|
||||
pub mod protocol;
|
||||
pub mod secret;
|
||||
pub mod tool_catalog;
|
||||
@@ -38,15 +41,20 @@ pub mod domain {
|
||||
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel,
|
||||
ProductEdition,
|
||||
};
|
||||
pub use crate::execution::{
|
||||
ExecutionErrorCode, ExecutionFailure, ExecutionLocale, ExecutionOrigin,
|
||||
ExecutionOriginError, ExecutionStage, ExecutionSuccess, OutcomeCertainty, Retryability,
|
||||
};
|
||||
pub use crate::ids::{
|
||||
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
|
||||
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
|
||||
UserSessionId, WorkspaceId,
|
||||
InvocationLogId, OperationId, PlatformApiKeyId, ProductEventId, SampleId, SecretId, ToolId,
|
||||
UserId, UserSessionId, WorkspaceId,
|
||||
};
|
||||
pub use crate::observability::{
|
||||
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
|
||||
InvocationStatus, UsagePeriod, UsageRollup, sanitize_invocation_preview,
|
||||
};
|
||||
pub use crate::onboarding::{OnboardingProjection, OnboardingStep, OnboardingStepId};
|
||||
pub use crate::operation::{
|
||||
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
|
||||
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalMode,
|
||||
@@ -54,6 +62,10 @@ pub mod domain {
|
||||
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy,
|
||||
RestTarget, RetryPolicy, Samples, Target, ToolDescription, ToolExample, WizardState,
|
||||
};
|
||||
pub use crate::product_event::{
|
||||
OnboardingMilestone, PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES, PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX, ProductEvent, ProductEventKind,
|
||||
};
|
||||
pub use crate::protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
|
||||
pub use crate::secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
pub use crate::tool_catalog::{ToolCatalogAnalysis, ToolCatalogBudget};
|
||||
@@ -89,9 +101,9 @@ pub mod ports {
|
||||
MeteringEvent, MeteringSink, NoopMeteringSink, SharedMeteringSink,
|
||||
};
|
||||
pub use crate::ext::protocol::{
|
||||
AdapterRegistry, AdapterResponse, ExecutionMode, MeteringContext, PreparedRequest,
|
||||
ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope, RuntimeRequestContext,
|
||||
SharedProtocolAdapter,
|
||||
AdapterRegistry, AdapterResponse, DispatchEvidence, ExecutionMode, MeteringContext,
|
||||
PreparedRequest, ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope,
|
||||
RuntimeRequestContext, SharedProtocolAdapter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +130,10 @@ pub use correlation::{CorrelationContext, CorrelationError, RequestId, TraceCont
|
||||
pub use edition::{
|
||||
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition,
|
||||
};
|
||||
pub use execution::{
|
||||
ConfirmationChallenge, ExecutionErrorCode, ExecutionFailure, ExecutionLocale, ExecutionOrigin,
|
||||
ExecutionOriginError, ExecutionStage, ExecutionSuccess, OutcomeCertainty, Retryability,
|
||||
};
|
||||
pub use ext::access::{
|
||||
OwnerOnlyPolicyEngine, PolicyAction, PolicyDecision, PolicyEngine, PolicyScope, SessionActor,
|
||||
};
|
||||
@@ -133,25 +149,32 @@ pub use ext::auth::{
|
||||
pub use ext::capability::{CapabilityProfile, CommunityCapabilityProfile};
|
||||
pub use ext::metering::{MeteringEvent, MeteringSink, NoopMeteringSink, SharedMeteringSink};
|
||||
pub use ext::protocol::{
|
||||
AdapterRegistry, AdapterResponse, ExecutionMode, MeteringContext, PreparedRequest,
|
||||
ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope, RuntimeRequestContext,
|
||||
SharedProtocolAdapter,
|
||||
AdapterRegistry, AdapterResponse, DispatchEvidence, ExecutionMode, MeteringContext,
|
||||
PreparedRequest, ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope,
|
||||
RuntimeRequestContext, SharedProtocolAdapter,
|
||||
};
|
||||
pub use ids::{
|
||||
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
|
||||
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
|
||||
UserSessionId, WorkspaceId,
|
||||
InvocationLogId, OperationId, PlatformApiKeyId, ProductEventId, SampleId, SecretId, ToolId,
|
||||
UserId, UserSessionId, WorkspaceId,
|
||||
};
|
||||
pub use observability::{
|
||||
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
|
||||
InvocationStatus, UsagePeriod, UsageRollup, sanitize_invocation_preview,
|
||||
};
|
||||
pub use onboarding::{OnboardingProjection, OnboardingStep, OnboardingStepId};
|
||||
pub use operation::{
|
||||
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
|
||||
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalMode,
|
||||
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
|
||||
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget,
|
||||
RetryPolicy, Samples, Target, ToolDescription, ToolExample, WizardState,
|
||||
OperationAvailability, OperationLifecycle, OperationLifecycleAction, OperationLifecycleError,
|
||||
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, OperationVersionState,
|
||||
ResponseCachePolicy, RestTarget, RetryPolicy, Samples, Target, ToolDescription, ToolExample,
|
||||
WizardState,
|
||||
};
|
||||
pub use product_event::{
|
||||
OnboardingMilestone, PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES, PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX, ProductEvent, ProductEventKind,
|
||||
};
|
||||
pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
|
||||
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
|
||||
@@ -2,12 +2,18 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{AgentId, OperationId, WorkspaceId};
|
||||
use crate::{
|
||||
AgentId, ExecutionErrorCode, ExecutionStage, OperationId, OutcomeCertainty, Retryability,
|
||||
WorkspaceId,
|
||||
};
|
||||
|
||||
pub const INVOCATION_PREVIEW_MAX_BYTES: usize = 16 * 1024;
|
||||
pub const INVOCATION_PREVIEW_MAX_DEPTH: usize = 12;
|
||||
pub const INVOCATION_PREVIEW_MAX_OBJECT_FIELDS: usize = 64;
|
||||
pub const INVOCATION_PREVIEW_MAX_ARRAY_ITEMS: usize = 64;
|
||||
|
||||
pub fn sanitize_invocation_preview(value: &Value) -> Value {
|
||||
let redacted = redact_sensitive_fields(value);
|
||||
let redacted = redact_sensitive_fields(value, 0);
|
||||
let Ok(encoded) = serde_json::to_vec(&redacted) else {
|
||||
return Value::Null;
|
||||
};
|
||||
@@ -17,27 +23,63 @@ pub fn sanitize_invocation_preview(value: &Value) -> Value {
|
||||
let preview = String::from_utf8_lossy(&encoded[..INVOCATION_PREVIEW_MAX_BYTES]).into_owned();
|
||||
json!({
|
||||
"truncated": true,
|
||||
"reason": "max_bytes",
|
||||
"original_bytes": encoded.len(),
|
||||
"preview": preview,
|
||||
})
|
||||
}
|
||||
|
||||
fn redact_sensitive_fields(value: &Value) -> Value {
|
||||
fn truncation_marker(reason: &'static str, omitted: usize) -> Value {
|
||||
json!({
|
||||
"truncated": true,
|
||||
"reason": reason,
|
||||
"omitted": omitted,
|
||||
})
|
||||
}
|
||||
|
||||
fn redact_sensitive_fields(value: &Value, depth: usize) -> Value {
|
||||
if depth >= INVOCATION_PREVIEW_MAX_DEPTH {
|
||||
return truncation_marker("max_depth", 1);
|
||||
}
|
||||
match value {
|
||||
Value::Object(object) => Value::Object(
|
||||
object
|
||||
Value::Object(object) => {
|
||||
let mut redacted = Map::new();
|
||||
let mut omitted = 0usize;
|
||||
for (index, (key, value)) in object.iter().enumerate() {
|
||||
if index >= INVOCATION_PREVIEW_MAX_OBJECT_FIELDS {
|
||||
omitted += 1;
|
||||
continue;
|
||||
}
|
||||
let value = if is_sensitive_key(key) {
|
||||
Value::String("[REDACTED]".to_owned())
|
||||
} else {
|
||||
redact_sensitive_fields(value, depth + 1)
|
||||
};
|
||||
redacted.insert(key.clone(), value);
|
||||
}
|
||||
if omitted > 0 {
|
||||
redacted.insert(
|
||||
"_crank_truncated".to_owned(),
|
||||
truncation_marker("max_object_fields", omitted),
|
||||
);
|
||||
}
|
||||
Value::Object(redacted)
|
||||
}
|
||||
Value::Array(items) => {
|
||||
let mut redacted = items
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
let value = if is_sensitive_key(key) {
|
||||
Value::String("[REDACTED]".to_owned())
|
||||
} else {
|
||||
redact_sensitive_fields(value)
|
||||
};
|
||||
(key.clone(), value)
|
||||
})
|
||||
.collect::<Map<String, Value>>(),
|
||||
),
|
||||
Value::Array(items) => Value::Array(items.iter().map(redact_sensitive_fields).collect()),
|
||||
.take(INVOCATION_PREVIEW_MAX_ARRAY_ITEMS)
|
||||
.map(|value| redact_sensitive_fields(value, depth + 1))
|
||||
.collect::<Vec<_>>();
|
||||
let omitted = items.len().saturating_sub(redacted.len());
|
||||
if omitted > 0 {
|
||||
redacted.push(truncation_marker("max_array_items", omitted));
|
||||
}
|
||||
Value::Array(redacted)
|
||||
}
|
||||
Value::String(value) if looks_like_sensitive_value(value) => {
|
||||
Value::String("[REDACTED]".to_owned())
|
||||
}
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
@@ -63,6 +105,22 @@ fn is_sensitive_key(key: &str) -> bool {
|
||||
.any(|sensitive| compact.contains(sensitive))
|
||||
}
|
||||
|
||||
fn looks_like_sensitive_value(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
lower.starts_with("bearer ")
|
||||
|| lower.starts_with("basic ")
|
||||
|| lower.contains("password=")
|
||||
|| lower.contains("token=")
|
||||
|| lower.contains("secret=")
|
||||
|| lower.contains("api_key=")
|
||||
|| lower.contains("apikey=")
|
||||
|| lower.contains("authorization:")
|
||||
|| trimmed.starts_with("sk_")
|
||||
|| trimmed.starts_with("crk_")
|
||||
|| trimmed.contains("SECRET_")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvocationSource {
|
||||
@@ -111,7 +169,10 @@ pub struct InvocationLog {
|
||||
pub id: crate::ids::InvocationLogId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub agent_id: Option<AgentId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub platform_api_key_id: Option<crate::ids::PlatformApiKeyId>,
|
||||
pub operation_id: OperationId,
|
||||
pub operation_version: Option<u32>,
|
||||
pub source: InvocationSource,
|
||||
pub level: InvocationLevel,
|
||||
pub status: InvocationStatus,
|
||||
@@ -122,6 +183,10 @@ pub struct InvocationLog {
|
||||
pub status_code: Option<u16>,
|
||||
pub duration_ms: u64,
|
||||
pub error_kind: Option<String>,
|
||||
pub execution_stage: Option<ExecutionStage>,
|
||||
pub execution_error_code: Option<ExecutionErrorCode>,
|
||||
pub retryability: Option<Retryability>,
|
||||
pub outcome_certainty: Option<OutcomeCertainty>,
|
||||
pub request_preview: Value,
|
||||
pub response_preview: Value,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
@@ -148,10 +213,12 @@ mod tests {
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{
|
||||
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
|
||||
InvocationStatus, UsagePeriod, sanitize_invocation_preview,
|
||||
INVOCATION_PREVIEW_MAX_ARRAY_ITEMS, INVOCATION_PREVIEW_MAX_BYTES,
|
||||
INVOCATION_PREVIEW_MAX_DEPTH, INVOCATION_PREVIEW_MAX_OBJECT_FIELDS, InvocationLevel,
|
||||
InvocationLog, InvocationSource, InvocationStatus, UsagePeriod,
|
||||
sanitize_invocation_preview,
|
||||
};
|
||||
use crate::{AgentId, OperationId, WorkspaceId, ids::InvocationLogId};
|
||||
use crate::{AgentId, OperationId, OutcomeCertainty, WorkspaceId, ids::InvocationLogId};
|
||||
|
||||
fn timestamp(value: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||
@@ -163,7 +230,9 @@ mod tests {
|
||||
id: InvocationLogId::new("log_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: Some(AgentId::new("agent_01")),
|
||||
platform_api_key_id: None,
|
||||
operation_id: OperationId::new("op_01"),
|
||||
operation_version: Some(1),
|
||||
source: InvocationSource::AdminTestRun,
|
||||
level: InvocationLevel::Info,
|
||||
status: InvocationStatus::Ok,
|
||||
@@ -174,6 +243,10 @@ mod tests {
|
||||
status_code: Some(200),
|
||||
duration_ms: 123,
|
||||
error_kind: None,
|
||||
execution_stage: None,
|
||||
execution_error_code: None,
|
||||
retryability: None,
|
||||
outcome_certainty: Some(OutcomeCertainty::Certain),
|
||||
request_preview: json!({"input": "value"}),
|
||||
response_preview: json!({"ok": true}),
|
||||
created_at: timestamp("2026-04-19T12:34:56Z"),
|
||||
@@ -202,6 +275,8 @@ mod tests {
|
||||
"api_key": "key",
|
||||
"refreshToken": "token",
|
||||
"client_secret_value": "secret",
|
||||
"body": "Bearer SECRET_APPROVAL_CANARY",
|
||||
"neutral": "token=SECRET_APPROVAL_CANARY",
|
||||
"value": 42
|
||||
}
|
||||
}));
|
||||
@@ -210,6 +285,8 @@ mod tests {
|
||||
assert_eq!(preview["nested"]["api_key"], "[REDACTED]");
|
||||
assert_eq!(preview["nested"]["refreshToken"], "[REDACTED]");
|
||||
assert_eq!(preview["nested"]["client_secret_value"], "[REDACTED]");
|
||||
assert_eq!(preview["nested"]["body"], "[REDACTED]");
|
||||
assert_eq!(preview["nested"]["neutral"], "[REDACTED]");
|
||||
assert_eq!(preview["nested"]["value"], 42);
|
||||
}
|
||||
|
||||
@@ -220,6 +297,45 @@ mod tests {
|
||||
}));
|
||||
|
||||
assert_eq!(preview["truncated"], true);
|
||||
assert_eq!(preview["reason"], "max_bytes");
|
||||
assert!(preview["original_bytes"].as_u64().unwrap() > INVOCATION_PREVIEW_MAX_BYTES as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_preview_depth_fields_and_array_items() {
|
||||
let mut nested = json!({"secret_token": "SECRET_VALUE"});
|
||||
for _ in 0..(INVOCATION_PREVIEW_MAX_DEPTH + 4) {
|
||||
nested = json!({ "nested": nested });
|
||||
}
|
||||
let deep_preview = sanitize_invocation_preview(&nested);
|
||||
assert!(
|
||||
serde_json::to_string(&deep_preview)
|
||||
.unwrap()
|
||||
.contains("\"reason\":\"max_depth\"")
|
||||
);
|
||||
assert!(
|
||||
!serde_json::to_string(&deep_preview)
|
||||
.unwrap()
|
||||
.contains("SECRET_VALUE")
|
||||
);
|
||||
|
||||
let wide = serde_json::Value::Object(
|
||||
(0..(INVOCATION_PREVIEW_MAX_OBJECT_FIELDS + 5))
|
||||
.map(|index| (format!("field_{index:03}"), json!(index)))
|
||||
.collect(),
|
||||
);
|
||||
let wide_preview = sanitize_invocation_preview(&wide);
|
||||
assert_eq!(
|
||||
wide_preview["_crank_truncated"]["reason"],
|
||||
"max_object_fields"
|
||||
);
|
||||
|
||||
let array_preview = sanitize_invocation_preview(&json!(
|
||||
(0..(INVOCATION_PREVIEW_MAX_ARRAY_ITEMS + 3)).collect::<Vec<_>>()
|
||||
));
|
||||
assert_eq!(
|
||||
array_preview.as_array().unwrap().last().unwrap()["reason"],
|
||||
"max_array_items"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{AgentId, InvocationLogId, OperationId, PlatformApiKeyId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OnboardingStepId {
|
||||
Operation,
|
||||
Test,
|
||||
PublishOperation,
|
||||
Agent,
|
||||
Key,
|
||||
McpConnection,
|
||||
FirstCall,
|
||||
}
|
||||
|
||||
impl OnboardingStepId {
|
||||
pub const ORDERED: [Self; 7] = [
|
||||
Self::Operation,
|
||||
Self::Test,
|
||||
Self::PublishOperation,
|
||||
Self::Agent,
|
||||
Self::Key,
|
||||
Self::McpConnection,
|
||||
Self::FirstCall,
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OnboardingStep {
|
||||
pub id: OnboardingStepId,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OnboardingProjection {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub revision: i64,
|
||||
pub completed: bool,
|
||||
pub was_completed: bool,
|
||||
pub steps: Vec<OnboardingStep>,
|
||||
pub operation_id: Option<OperationId>,
|
||||
pub operation_version: Option<u32>,
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub catalog_revision: Option<i64>,
|
||||
pub platform_api_key_id: Option<PlatformApiKeyId>,
|
||||
pub first_call_log_id: Option<InvocationLogId>,
|
||||
pub first_call_tool_name: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub first_call_at: Option<OffsetDateTime>,
|
||||
pub first_call_request_id: Option<String>,
|
||||
pub first_call_trace_id: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub eligible_since: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
impl OnboardingProjection {
|
||||
pub fn step(&self, id: OnboardingStepId) -> Option<&OnboardingStep> {
|
||||
self.steps.iter().find(|step| step.id == id)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,73 @@ pub enum OperationStatus {
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperationAvailability {
|
||||
Active,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperationVersionState {
|
||||
Draft,
|
||||
Published,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OperationLifecycleAction {
|
||||
SaveDraft,
|
||||
Publish,
|
||||
Archive,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum OperationLifecycleError {
|
||||
#[error("operation is archived")]
|
||||
Archived,
|
||||
#[error("operation lifecycle transition is invalid")]
|
||||
InvalidTransition,
|
||||
#[error("operation deletion would destroy durable history")]
|
||||
DurableHistory,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OperationLifecycle {
|
||||
pub availability: OperationAvailability,
|
||||
pub current: OperationVersionState,
|
||||
pub ever_published: bool,
|
||||
pub has_durable_references: bool,
|
||||
}
|
||||
|
||||
impl OperationLifecycle {
|
||||
pub fn validate(self, action: OperationLifecycleAction) -> Result<(), OperationLifecycleError> {
|
||||
if self.availability == OperationAvailability::Archived {
|
||||
return match action {
|
||||
OperationLifecycleAction::Archive => Ok(()),
|
||||
_ => Err(OperationLifecycleError::Archived),
|
||||
};
|
||||
}
|
||||
match action {
|
||||
OperationLifecycleAction::SaveDraft => Ok(()),
|
||||
OperationLifecycleAction::Publish if self.current == OperationVersionState::Draft => {
|
||||
Ok(())
|
||||
}
|
||||
OperationLifecycleAction::Archive => Ok(()),
|
||||
OperationLifecycleAction::Delete
|
||||
if !self.ever_published && !self.has_durable_references =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
OperationLifecycleAction::Delete => Err(OperationLifecycleError::DurableHistory),
|
||||
OperationLifecycleAction::Publish => Err(OperationLifecycleError::InvalidTransition),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RestTarget {
|
||||
pub base_url: String,
|
||||
pub method: HttpMethod,
|
||||
@@ -44,11 +110,13 @@ pub enum Target {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RetryPolicy {
|
||||
pub max_attempts: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResponseCachePolicy {
|
||||
pub ttl_ms: u64,
|
||||
}
|
||||
@@ -62,6 +130,7 @@ pub enum IdempotencyMode {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IdempotencyPolicy {
|
||||
pub mode: IdempotencyMode,
|
||||
pub ttl_ms: u64,
|
||||
@@ -92,11 +161,13 @@ impl OperationSafetyClass {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfirmationPolicy {
|
||||
pub ttl_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OperationSafetyPolicy {
|
||||
pub class: OperationSafetyClass,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -130,6 +201,7 @@ pub enum OperationApprovalMode {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OperationApprovalPolicy {
|
||||
pub required: bool,
|
||||
#[serde(default)]
|
||||
@@ -143,6 +215,7 @@ pub struct OperationApprovalPolicy {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExecutionConfig {
|
||||
pub timeout_ms: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -162,11 +235,13 @@ pub struct ExecutionConfig {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ToolExample {
|
||||
pub input: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ToolDescription {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
@@ -284,6 +359,23 @@ impl<TSchema, TMapping> Operation<TSchema, TMapping> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<TSchema: PartialEq, TMapping: PartialEq> Operation<TSchema, TMapping> {
|
||||
pub fn portable_semantically_eq(&self, other: &Self) -> bool {
|
||||
self.name == other.name
|
||||
&& self.display_name == other.display_name
|
||||
&& self.category == other.category
|
||||
&& self.protocol == other.protocol
|
||||
&& self.security_level == other.security_level
|
||||
&& self.target == other.target
|
||||
&& self.input_schema == other.input_schema
|
||||
&& self.output_schema == other.output_schema
|
||||
&& self.input_mapping == other.input_mapping
|
||||
&& self.output_mapping == other.output_mapping
|
||||
&& self.execution_config == other.execution_config
|
||||
&& self.tool_description == other.tool_description
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
@@ -296,8 +388,9 @@ mod tests {
|
||||
edition::OperationSecurityLevel,
|
||||
ids::{AuthProfileId, OperationId},
|
||||
operation::{
|
||||
ConfigExport, ExecutionConfig, Operation, OperationStatus, RestTarget, Samples, Target,
|
||||
ToolDescription, ToolExample,
|
||||
ConfigExport, ExecutionConfig, Operation, OperationAvailability, OperationLifecycle,
|
||||
OperationLifecycleAction, OperationLifecycleError, OperationStatus,
|
||||
OperationVersionState, RestTarget, Samples, Target, ToolDescription, ToolExample,
|
||||
},
|
||||
protocol::{AuthKind, ExportMode, HttpMethod, Protocol},
|
||||
};
|
||||
@@ -335,6 +428,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_rejects_publish_rewind_and_durable_delete() {
|
||||
let published = OperationLifecycle {
|
||||
availability: OperationAvailability::Active,
|
||||
current: OperationVersionState::Published,
|
||||
ever_published: true,
|
||||
has_durable_references: true,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
published.validate(OperationLifecycleAction::Publish),
|
||||
Err(OperationLifecycleError::InvalidTransition)
|
||||
);
|
||||
assert_eq!(
|
||||
published.validate(OperationLifecycleAction::Delete),
|
||||
Err(OperationLifecycleError::DurableHistory)
|
||||
);
|
||||
assert_eq!(
|
||||
published.validate(OperationLifecycleAction::SaveDraft),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archived_lifecycle_is_terminal_but_archive_retry_is_idempotent() {
|
||||
let archived = OperationLifecycle {
|
||||
availability: OperationAvailability::Archived,
|
||||
current: OperationVersionState::Published,
|
||||
ever_published: true,
|
||||
has_durable_references: true,
|
||||
};
|
||||
|
||||
assert_eq!(archived.validate(OperationLifecycleAction::Archive), Ok(()));
|
||||
assert_eq!(
|
||||
archived.validate(OperationLifecycleAction::SaveDraft),
|
||||
Err(OperationLifecycleError::Archived)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_semantic_equality_ignores_persistence_identity() {
|
||||
let left = test_operation(OperationStatus::Draft);
|
||||
let mut right = left.clone();
|
||||
right.id = OperationId::new("op_other");
|
||||
right.version = 9;
|
||||
right.status = OperationStatus::Published;
|
||||
right.updated_at = timestamp("2026-03-25T09:00:00Z");
|
||||
right.published_at = Some(timestamp("2026-03-25T09:00:00Z"));
|
||||
|
||||
assert!(left.portable_semantically_eq(&right));
|
||||
right.display_name = "Changed".to_owned();
|
||||
assert!(!left.portable_semantically_eq(&right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_profile_serializes_secret_ids_without_secret_values() {
|
||||
let profile = AuthProfile {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{Date, OffsetDateTime};
|
||||
|
||||
use crate::{ProductEventId, WorkspaceId};
|
||||
|
||||
pub const PRODUCT_EVENT_SCHEMA_VERSION: u16 = 1;
|
||||
pub const PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES: usize = 256;
|
||||
pub const PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX: &str = "onboarding:";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProductEventKind {
|
||||
OnboardingEligible,
|
||||
OnboardingStarted,
|
||||
OnboardingResumed,
|
||||
OnboardingDismissed,
|
||||
OnboardingAbandoned,
|
||||
OnboardingCompleted,
|
||||
}
|
||||
|
||||
impl ProductEventKind {
|
||||
pub const ALL: [Self; 6] = [
|
||||
Self::OnboardingEligible,
|
||||
Self::OnboardingStarted,
|
||||
Self::OnboardingResumed,
|
||||
Self::OnboardingDismissed,
|
||||
Self::OnboardingAbandoned,
|
||||
Self::OnboardingCompleted,
|
||||
];
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OnboardingEligible => "onboarding_eligible",
|
||||
Self::OnboardingStarted => "onboarding_started",
|
||||
Self::OnboardingResumed => "onboarding_resumed",
|
||||
Self::OnboardingDismissed => "onboarding_dismissed",
|
||||
Self::OnboardingAbandoned => "onboarding_abandoned",
|
||||
Self::OnboardingCompleted => "onboarding_completed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OnboardingMilestone {
|
||||
Operation,
|
||||
Test,
|
||||
PublishOperation,
|
||||
Agent,
|
||||
Key,
|
||||
McpConnection,
|
||||
FirstCall,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProductEvent {
|
||||
pub id: ProductEventId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub kind: ProductEventKind,
|
||||
pub schema_version: u16,
|
||||
pub milestone: Option<OnboardingMilestone>,
|
||||
pub eligible: bool,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub eligible_since: Option<OffsetDateTime>,
|
||||
pub idempotency_key: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub occurred_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl ProductEvent {
|
||||
pub fn occurred_on_utc(&self) -> Date {
|
||||
self.occurred_at.to_offset(time::UtcOffset::UTC).date()
|
||||
}
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
let eligibility_is_valid = match self.kind {
|
||||
ProductEventKind::OnboardingEligible => self.eligible && self.eligible_since.is_some(),
|
||||
_ => !self.eligible || self.eligible_since.is_some(),
|
||||
};
|
||||
self.schema_version == PRODUCT_EVENT_SCHEMA_VERSION
|
||||
&& !self.idempotency_key.is_empty()
|
||||
&& self.idempotency_key.len() <= PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES
|
||||
&& eligibility_is_valid
|
||||
}
|
||||
|
||||
pub fn is_semantic_replay_of(&self, recorded: &Self) -> bool {
|
||||
self.workspace_id == recorded.workspace_id
|
||||
&& self.kind == recorded.kind
|
||||
&& self.schema_version == recorded.schema_version
|
||||
&& self.milestone == recorded.milestone
|
||||
&& self.eligible == recorded.eligible
|
||||
&& self.eligible_since == recorded.eligible_since
|
||||
&& self.idempotency_key == recorded.idempotency_key
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ProductEventId, WorkspaceId};
|
||||
|
||||
#[test]
|
||||
fn vocabulary_is_closed_and_stable() {
|
||||
assert_eq!(ProductEventKind::ALL.len(), 6);
|
||||
assert_eq!(
|
||||
ProductEventKind::OnboardingCompleted.as_str(),
|
||||
"onboarding_completed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eligible_event_requires_explicit_cohort_timestamp() {
|
||||
let event = ProductEvent {
|
||||
id: ProductEventId::new("pe_eligible"),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
kind: ProductEventKind::OnboardingEligible,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: true,
|
||||
eligible_since: None,
|
||||
idempotency_key: "eligible:first-login".to_owned(),
|
||||
occurred_at: OffsetDateTime::UNIX_EPOCH,
|
||||
};
|
||||
assert!(!event.is_valid());
|
||||
assert!(
|
||||
ProductEvent {
|
||||
eligible_since: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..event
|
||||
}
|
||||
.is_valid()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_replay_ignores_transport_identity_and_retry_time() {
|
||||
let recorded = ProductEvent {
|
||||
id: ProductEventId::new("pe_recorded"),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
kind: ProductEventKind::OnboardingStarted,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: false,
|
||||
eligible_since: None,
|
||||
idempotency_key: "ui:started:1".to_owned(),
|
||||
occurred_at: OffsetDateTime::UNIX_EPOCH,
|
||||
};
|
||||
let replay = ProductEvent {
|
||||
id: ProductEventId::new("pe_retry"),
|
||||
occurred_at: OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1),
|
||||
..recorded.clone()
|
||||
};
|
||||
assert!(replay.is_semantic_replay_of(&recorded));
|
||||
assert!(
|
||||
!ProductEvent {
|
||||
kind: ProductEventKind::OnboardingDismissed,
|
||||
..replay
|
||||
}
|
||||
.is_semantic_replay_of(&recorded)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use crank_core::{
|
||||
AgentId, CorrelationContext, ExecutionErrorCode, ExecutionFailure, ExecutionOrigin,
|
||||
ExecutionStage, OutcomeCertainty, Retryability,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn frozen_execution_taxonomy_is_closed_and_consistent() {
|
||||
let cases = [
|
||||
(
|
||||
ExecutionErrorCode::AuthorizationDenied,
|
||||
ExecutionStage::Authorization,
|
||||
Retryability::Never,
|
||||
OutcomeCertainty::Certain,
|
||||
),
|
||||
(
|
||||
ExecutionErrorCode::InputSchemaInvalid,
|
||||
ExecutionStage::InputSchema,
|
||||
Retryability::Never,
|
||||
OutcomeCertainty::Certain,
|
||||
),
|
||||
(
|
||||
ExecutionErrorCode::ExecutionOverloaded,
|
||||
ExecutionStage::Admission,
|
||||
Retryability::AfterDelay,
|
||||
OutcomeCertainty::Certain,
|
||||
),
|
||||
(
|
||||
ExecutionErrorCode::ConfirmationRequired,
|
||||
ExecutionStage::Admission,
|
||||
Retryability::RequiresConfirmation,
|
||||
OutcomeCertainty::Certain,
|
||||
),
|
||||
(
|
||||
ExecutionErrorCode::IdempotencyOutcomeUnknown,
|
||||
ExecutionStage::Admission,
|
||||
Retryability::ManualReconcile,
|
||||
OutcomeCertainty::OutcomeUnknown,
|
||||
),
|
||||
(
|
||||
ExecutionErrorCode::RuntimeInternal,
|
||||
ExecutionStage::Runtime,
|
||||
Retryability::Never,
|
||||
OutcomeCertainty::Certain,
|
||||
),
|
||||
];
|
||||
|
||||
for (code, stage, retryability, certainty) in cases {
|
||||
let descriptor = ExecutionFailure::new(code, CorrelationContext::generate());
|
||||
assert_eq!(descriptor.stage(), stage);
|
||||
assert_eq!(descriptor.retryability(), retryability);
|
||||
assert_eq!(descriptor.outcome_certainty(), certainty);
|
||||
assert_eq!(descriptor.error_code(), code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_origin_enforces_agent_scope() {
|
||||
let agent_id = AgentId::new("agent_01");
|
||||
assert!(ExecutionOrigin::AdminDraft.validate_agent(None).is_ok());
|
||||
assert!(
|
||||
ExecutionOrigin::AdminDraft
|
||||
.validate_agent(Some(&agent_id))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
ExecutionOrigin::AgentSnapshot
|
||||
.validate_agent(Some(&agent_id))
|
||||
.is_ok()
|
||||
);
|
||||
assert!(ExecutionOrigin::AgentSnapshot.validate_agent(None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_uncertainty_is_allowed_only_for_ambiguous_codes() {
|
||||
let correlation = CorrelationContext::generate();
|
||||
let timeout = ExecutionFailure::new(ExecutionErrorCode::UpstreamTimeout, correlation.clone())
|
||||
.with_dispatch_uncertainty();
|
||||
assert_eq!(timeout.retryability(), Retryability::ManualReconcile);
|
||||
assert_eq!(
|
||||
timeout.outcome_certainty(),
|
||||
OutcomeCertainty::OutcomeUnknown
|
||||
);
|
||||
|
||||
let validation = ExecutionFailure::new(ExecutionErrorCode::InputSchemaInvalid, correlation)
|
||||
.with_dispatch_uncertainty();
|
||||
assert_eq!(validation.retryability(), Retryability::Never);
|
||||
assert_eq!(validation.outcome_certainty(), OutcomeCertainty::Certain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_metadata_is_code_specific_and_bounded() {
|
||||
let correlation = CorrelationContext::generate();
|
||||
assert!(
|
||||
ExecutionFailure::new(ExecutionErrorCode::InputSchemaInvalid, correlation.clone())
|
||||
.try_with_retry_after_ms(1)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
ExecutionFailure::new(ExecutionErrorCode::UpstreamRateLimited, correlation.clone())
|
||||
.try_with_retry_after_ms(3_600_001)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
ExecutionFailure::new(ExecutionErrorCode::RuntimeInternal, correlation.clone())
|
||||
.try_with_confirmation("token", 1)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
ExecutionFailure::new(
|
||||
ExecutionErrorCode::ConfirmationRequired,
|
||||
correlation.clone()
|
||||
)
|
||||
.try_with_confirmation("x".repeat(4_097), 1)
|
||||
.is_err()
|
||||
);
|
||||
let challenge = ExecutionFailure::new(ExecutionErrorCode::ConfirmationRequired, correlation)
|
||||
.try_with_confirmation("secret-canary", 1_000)
|
||||
.expect("bounded confirmation");
|
||||
assert!(!format!("{challenge:?}").contains("secret-canary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_taxonomy_values_are_rejected_by_serde() {
|
||||
assert!(serde_json::from_str::<ExecutionStage>("\"unknown\"").is_err());
|
||||
assert!(serde_json::from_str::<ExecutionErrorCode>("\"unknown\"").is_err());
|
||||
assert!(serde_json::from_str::<Retryability>("\"automatic\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_published_code_has_stable_wire_value_stage_and_localized_messages() {
|
||||
let mut wire_values = std::collections::BTreeSet::new();
|
||||
for code in ExecutionErrorCode::ALL {
|
||||
assert!(wire_values.insert(code.as_str()));
|
||||
assert_eq!(serde_json::to_value(code).unwrap(), code.as_str());
|
||||
assert!(!code.message(crank_core::ExecutionLocale::Ru).is_empty());
|
||||
assert!(!code.message(crank_core::ExecutionLocale::En).is_empty());
|
||||
assert!(!code.stage().as_str().is_empty());
|
||||
assert!(!code.retryability().as_str().is_empty());
|
||||
assert!(!code.outcome_certainty().as_str().is_empty());
|
||||
}
|
||||
assert_eq!(wire_values.len(), ExecutionErrorCode::ALL.len());
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub enum MappingTargetContext {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MappingCondition {
|
||||
pub source: String,
|
||||
pub equals: Value,
|
||||
@@ -31,11 +32,13 @@ pub enum TransformKind {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Transform {
|
||||
pub kind: TransformKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MappingRule {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
@@ -52,6 +55,7 @@ pub struct MappingRule {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MappingSet {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<MappingRule>,
|
||||
|
||||
@@ -9,8 +9,8 @@ use crate::labels::{
|
||||
pub const METRIC_SCHEMA_VERSION: u32 = 1;
|
||||
pub const MAX_METRIC_FAMILIES: usize = 128;
|
||||
pub const MAX_PRODUCT_LABELS_PER_FAMILY: usize = 4;
|
||||
pub const MAX_LOGICAL_SERIES_PER_PROCESS: usize = 5_000;
|
||||
pub const MAX_RENDERED_SERIES_PER_PROCESS: usize = 15_000;
|
||||
pub const MAX_LOGICAL_SERIES_PER_PROCESS: usize = 5_250;
|
||||
pub const MAX_RENDERED_SERIES_PER_PROCESS: usize = 16_750;
|
||||
pub const MAX_EXPOSITION_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -167,13 +167,19 @@ pub const HTTP_ROUTE_DOMAIN: &[&str] = &[
|
||||
"/health",
|
||||
"/ready",
|
||||
"/api/auth/login",
|
||||
"/api/auth/bootstrap/status",
|
||||
"/api/auth/bootstrap/complete",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/session",
|
||||
"/api/auth/session/csrf",
|
||||
"/api/auth/profile",
|
||||
"/api/auth/password",
|
||||
"/api/admin/capabilities",
|
||||
"/api/admin/workspaces",
|
||||
"/api/admin/workspaces/{workspace_id}",
|
||||
"/api/admin/workspaces/{workspace_id}/onboarding",
|
||||
"/api/admin/workspaces/{workspace_id}/onboarding/events",
|
||||
"/api/admin/workspaces/{workspace_id}/onboarding/reset-selection",
|
||||
"/api/admin/workspaces/{workspace_id}/operations",
|
||||
"/api/admin/workspaces/{workspace_id}/imports/openapi/preview",
|
||||
"/api/admin/workspaces/{workspace_id}/imports/openapi/{job_id}/create",
|
||||
@@ -209,10 +215,14 @@ pub const HTTP_ROUTE_DOMAIN: &[&str] = &[
|
||||
"/api/admin/workspaces/{workspace_id}/secrets/{secret_id}/rotate",
|
||||
"/api/admin/workspaces/{workspace_id}/export",
|
||||
"/api/admin/workspaces/{workspace_id}/logs",
|
||||
"/api/admin/workspaces/{workspace_id}/logs/export.csv",
|
||||
"/api/admin/workspaces/{workspace_id}/logs/{log_id}",
|
||||
"/api/admin/workspaces/{workspace_id}/approvals",
|
||||
"/api/admin/workspaces/{workspace_id}/approvals/{approval_id}",
|
||||
"/api/admin/workspaces/{workspace_id}/approvals/{approval_id}/approve",
|
||||
"/api/admin/workspaces/{workspace_id}/approvals/{approval_id}/deny",
|
||||
"/api/admin/workspaces/{workspace_id}/usage",
|
||||
"/api/admin/workspaces/{workspace_id}/usage/export.csv",
|
||||
"/api/admin/workspaces/{workspace_id}/usage/operations/{operation_id}",
|
||||
"/api/admin/workspaces/{workspace_id}/usage/agents/{agent_id}",
|
||||
"/v1/{workspace_slug}/{agent_slug}",
|
||||
@@ -294,7 +304,7 @@ const METRIC_SCHEMA: &[MetricDefinition] = &[
|
||||
],
|
||||
&[],
|
||||
BOTH,
|
||||
57 * 10 * 6,
|
||||
67 * 10 * 6,
|
||||
"Total HTTP requests.",
|
||||
),
|
||||
metric(
|
||||
@@ -308,7 +318,7 @@ const METRIC_SCHEMA: &[MetricDefinition] = &[
|
||||
],
|
||||
DURATION_BUCKETS_SECONDS,
|
||||
BOTH,
|
||||
57 * 10,
|
||||
67 * 10,
|
||||
"HTTP request duration in seconds.",
|
||||
),
|
||||
metric(
|
||||
|
||||
@@ -46,16 +46,16 @@ fn schema_v1_is_complete_deterministic_and_inside_the_frozen_budget() {
|
||||
|
||||
#[test]
|
||||
fn frozen_budget_edges_are_exact() {
|
||||
assert!(validate_budget(4_999, 14_999).is_ok());
|
||||
assert!(validate_budget(5_000, 15_000).is_ok());
|
||||
assert!(validate_budget(5_001, 15_000).is_err());
|
||||
assert!(validate_budget(5_000, 15_001).is_err());
|
||||
assert_eq!(crank_metrics::max_exemplar_slots(), 9_170);
|
||||
assert!(validate_budget(5_249, 16_749).is_ok());
|
||||
assert!(validate_budget(5_250, 16_750).is_ok());
|
||||
assert!(validate_budget(5_251, 16_000).is_err());
|
||||
assert!(validate_budget(5_200, 16_751).is_err());
|
||||
assert_eq!(crank_metrics::max_exemplar_slots(), 10_570);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_registered_route_round_trips_without_dynamic_segments() {
|
||||
assert_eq!(HTTP_ROUTE_DOMAIN.len(), 57);
|
||||
assert_eq!(HTTP_ROUTE_DOMAIN.len(), 67);
|
||||
for route in HTTP_ROUTE_DOMAIN {
|
||||
assert_eq!(HttpRoute::from_matched_path(route).as_str(), *route);
|
||||
assert!(!route.contains("trace_id"));
|
||||
@@ -68,8 +68,8 @@ fn canonical_schema_validation_reports_its_category() {
|
||||
assert_eq!(
|
||||
schema_budget(),
|
||||
Ok(crank_metrics::SchemaBudget {
|
||||
logical_series: 4_513,
|
||||
rendered_series: 14_338
|
||||
logical_series: 5_213,
|
||||
rendered_series: 16_538
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,16 @@ fn process_projection_excludes_mcp_only_metrics_from_admin() {
|
||||
fn registered_admin_and_mcp_routes_are_exactly_the_schema_domain() {
|
||||
let admin_source = include_str!("../../../apps/admin-api/src/app.rs");
|
||||
let mcp_source = include_str!("../../crank-community-mcp/src/app.rs");
|
||||
let auth = ["/login", "/logout", "/session", "/profile", "/password"];
|
||||
let auth = [
|
||||
"/bootstrap/status",
|
||||
"/bootstrap/complete",
|
||||
"/login",
|
||||
"/logout",
|
||||
"/session",
|
||||
"/session/csrf",
|
||||
"/profile",
|
||||
"/password",
|
||||
];
|
||||
let admin_root = ["/capabilities", "/workspaces", "/workspaces/{workspace_id}"];
|
||||
let mut actual = BTreeSet::from(["unmatched".to_owned()]);
|
||||
for route in route_literals(admin_source) {
|
||||
|
||||
@@ -16,6 +16,16 @@ pub enum RegistryError {
|
||||
UserNotFound { user_id: String },
|
||||
#[error("user with email {email} already exists")]
|
||||
UserEmailAlreadyExists { email: String },
|
||||
#[error("admin bootstrap contract is not available")]
|
||||
AdminBootstrapUnavailable,
|
||||
#[error("admin bootstrap contract is invalid, expired or already used")]
|
||||
AdminBootstrapRejected,
|
||||
#[error("admin recovery request is invalid or unavailable")]
|
||||
AdminRecoveryRejected,
|
||||
#[error("admin login is temporarily rate limited")]
|
||||
AdminLoginRateLimited { retry_after_ms: i64 },
|
||||
#[error("admin csrf token is invalid or missing")]
|
||||
AdminCsrfRejected,
|
||||
#[error("membership for user {user_id} in workspace {workspace_id} was not found")]
|
||||
MembershipNotFound {
|
||||
workspace_id: String,
|
||||
@@ -25,8 +35,28 @@ pub enum RegistryError {
|
||||
InvitationNotFound { invitation_id: String },
|
||||
#[error("platform api key {key_id} was not found")]
|
||||
PlatformApiKeyNotFound { key_id: String },
|
||||
#[error("platform api key {key_id} is not active")]
|
||||
PlatformApiKeyInactive { key_id: String },
|
||||
#[error("platform api key with name {name} already exists in workspace {workspace_id}")]
|
||||
PlatformApiKeyNameAlreadyExists { workspace_id: String, name: String },
|
||||
#[error("secret {secret_id} was not found")]
|
||||
SecretNotFound { secret_id: String },
|
||||
#[error("secret {secret_id} is not active")]
|
||||
SecretInactive { secret_id: String },
|
||||
#[error("secret {secret_id} was updated concurrently")]
|
||||
SecretConcurrentUpdate { secret_id: String },
|
||||
#[error("master key identity is not compatible with registered epoch {epoch}")]
|
||||
MasterKeyIdentityMismatch { epoch: i64 },
|
||||
#[error("master key identity metadata is invalid")]
|
||||
InvalidMasterKeyIdentity,
|
||||
#[error("master key rotation is already active")]
|
||||
MasterKeyRotationInProgress,
|
||||
#[error("master key rotation {rotation_id} was not found")]
|
||||
MasterKeyRotationNotFound { rotation_id: String },
|
||||
#[error("master key rotation state is incompatible with requested transition")]
|
||||
MasterKeyRotationConflict,
|
||||
#[error("master key rotation verification failed")]
|
||||
MasterKeyRotationVerificationFailed,
|
||||
#[error("secret with name {name} already exists in workspace {workspace_id}")]
|
||||
SecretNameAlreadyExists { workspace_id: String, name: String },
|
||||
#[error("secret {secret_id} is referenced by auth profile {auth_profile_id}")]
|
||||
@@ -59,12 +89,40 @@ pub enum RegistryError {
|
||||
expected: u32,
|
||||
actual: u32,
|
||||
},
|
||||
#[error("operation {operation_id} is archived")]
|
||||
OperationArchived { operation_id: String },
|
||||
#[error("operation {operation_id} has a stale base version: expected {expected}, got {actual}")]
|
||||
OperationStaleVersion {
|
||||
operation_id: String,
|
||||
expected: u32,
|
||||
actual: u32,
|
||||
},
|
||||
#[error("operation {operation_id} cannot transition from {from} using {action}")]
|
||||
InvalidOperationTransition {
|
||||
operation_id: String,
|
||||
from: String,
|
||||
action: &'static str,
|
||||
},
|
||||
#[error("operation {operation_id} cannot be deleted because durable history exists")]
|
||||
OperationDeleteForbidden { operation_id: String },
|
||||
#[error("operation {operation_id} auth profile reference is unavailable")]
|
||||
OperationAuthProfileUnavailable { operation_id: String },
|
||||
#[error("agent {agent_id} expected next version {expected}, got {actual}")]
|
||||
InvalidAgentVersionSequence {
|
||||
agent_id: String,
|
||||
expected: u32,
|
||||
actual: u32,
|
||||
},
|
||||
#[error("agent version {version} for {agent_id} is immutable")]
|
||||
ImmutableAgentVersion { agent_id: String, version: u32 },
|
||||
#[error("agent {agent_id} state changed before mutation")]
|
||||
AgentStaleRevision { agent_id: String },
|
||||
#[error("agent {agent_id} cannot transition from {from} using {action}")]
|
||||
InvalidAgentTransition {
|
||||
agent_id: String,
|
||||
from: String,
|
||||
action: &'static str,
|
||||
},
|
||||
#[error("operation {operation_id} changed immutable field {field}")]
|
||||
ImmutableOperationFieldChanged {
|
||||
operation_id: String,
|
||||
@@ -84,4 +142,10 @@ pub enum RegistryError {
|
||||
InvalidNumericValue { field: &'static str, value: i64 },
|
||||
#[error("invalid correlation identity for field {field}")]
|
||||
InvalidCorrelationIdentity { field: &'static str },
|
||||
#[error("new invocation record is missing or has incompatible execution field {field}")]
|
||||
InvalidExecutionRecord { field: &'static str },
|
||||
#[error("onboarding state changed before presentation milestone mutation")]
|
||||
OnboardingStaleRevision,
|
||||
#[error("onboarding domain steps are not complete")]
|
||||
OnboardingIncomplete,
|
||||
}
|
||||
|
||||
@@ -13,32 +13,41 @@ pub use migrations::{
|
||||
|
||||
pub mod records {
|
||||
pub use crate::model::{
|
||||
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApprovalRequestRecord,
|
||||
AuthUserRecord, DescriptorKind, DescriptorMetadata, ImportJob, ImportJobApplyResult,
|
||||
ImportJobId, ImportJobKind, ImportJobStatus, InvitationRecord, InvocationHistoryLoss,
|
||||
InvocationHistoryLossCategory, InvocationHistoryWriteOutcome, InvocationLogRecord,
|
||||
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
||||
OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord,
|
||||
PublishedAgentCatalog, PublishedAgentTool, RegistryOperation, SampleKind, SecretRecord,
|
||||
SecretVersionRecord, SessionRecord, SkippedImportOperation, UsageAgentBreakdown,
|
||||
UsageBucket, UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
|
||||
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
AdminBootstrapContractRecord, AgentSummary, AgentVersionRecord, AppendProductEventOutcome,
|
||||
AppliedImportOperation, ApprovalRequestRecord, AuthUserRecord, DescriptorKind,
|
||||
DescriptorMetadata, ImportJob, ImportJobApplyResult, ImportJobId, ImportJobKind,
|
||||
ImportJobStatus, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome,
|
||||
InvocationRetentionPolicy, InvocationRetentionStatus, MasterKeyIdentityRecord,
|
||||
MasterKeyRotationRecord, MasterKeyRotationStatus, MembershipRecord,
|
||||
OnboardingMilestoneResult, OnboardingPresentationMilestone, OperationAgentRef,
|
||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||
Page, PlatformApiKeyRecord, ProductEventRecord, PublishedAgentCatalog, PublishedAgentTool,
|
||||
RegistryOperation, SampleKind, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||
SkippedImportOperation, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown,
|
||||
UsageOutcomeBreakdown, UsageOutcomeGroup, UsageRollupRecord, UsageSummary,
|
||||
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
|
||||
WorkspaceUpstreamId, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||
YamlImportJobStatus,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod requests {
|
||||
pub use crate::model::{
|
||||
ApplyImportJobRequest, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
||||
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DecideApprovalRequest, ExpireApprovalRequest, FinishApprovalRequest,
|
||||
FinishImportJobRequest, ImportConflictMode, ImportOperationDraft,
|
||||
ListApprovalRequestsQuery, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
|
||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageQuery,
|
||||
AdminSecurityAuditRequest, AppendProductEventRequest, ApplyImportJobRequest,
|
||||
ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
|
||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode,
|
||||
ImportOperationDraft, ListApprovalRequestsQuery, ListInvocationLogsQuery,
|
||||
ListProductEventsQuery, MasterKeyIdentityCandidate, PublishAgentRequest, PublishRequest,
|
||||
RecordOnboardingCompletionRequest, RecordOnboardingMilestoneRequest,
|
||||
RecoverAdminPasswordRequest, RotateSecretRequest, SaveAgentBindingsRequest,
|
||||
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, UpdateAgentSummaryRequest,
|
||||
UpdateWorkspaceRequest, UsageQuery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,26 +61,34 @@ pub mod infrastructure {
|
||||
}
|
||||
|
||||
pub use model::{
|
||||
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApplyImportJobRequest,
|
||||
ApprovalRequestRecord, AuthUserRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
||||
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DecideApprovalRequest, DescriptorKind, DescriptorMetadata, ExpireApprovalRequest,
|
||||
FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode, ImportJob,
|
||||
ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobStatus, ImportOperationDraft,
|
||||
InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||
OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord,
|
||||
PublishAgentRequest, PublishRequest, PublishedAgentCatalog, PublishedAgentTool,
|
||||
RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest,
|
||||
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
||||
SessionRecord, SkippedImportOperation, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageBucket, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
|
||||
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
|
||||
WorkspaceUpstreamId, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||
YamlImportJobStatus,
|
||||
AdminBootstrapContractRecord, AdminSecurityAuditRequest, AgentStateExpectation, AgentSummary,
|
||||
AgentVersionRecord, AppendProductEventOutcome, AppendProductEventRequest,
|
||||
AppliedImportOperation, ApplyImportJobRequest, ApprovalRequestRecord, AuthUserRecord,
|
||||
ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
|
||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
|
||||
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode,
|
||||
ImportJob, ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobStatus,
|
||||
ImportOperationDraft, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome,
|
||||
InvocationRetentionPolicy, InvocationRetentionStatus, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, ListProductEventsQuery, MASTER_KEY_CIPHER_CONTRACT,
|
||||
MasterKeyIdentityCandidate, MasterKeyIdentityRecord, MasterKeyRotationRecord,
|
||||
MasterKeyRotationStatus, MembershipRecord, OnboardingMilestoneResult,
|
||||
OnboardingPresentationMilestone, OperationAgentRef, OperationSampleMetadata,
|
||||
OperationStateExpectation, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||
Page, PlatformApiKeyRecord, ProductEventRecord, PublishAgentRequest, PublishRequest,
|
||||
PublishedAgentCatalog, PublishedAgentTool, RecordOnboardingCompletionRequest,
|
||||
RecordOnboardingMilestoneRequest, RecoverAdminPasswordRequest, RegistryOperation,
|
||||
RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||
SkippedImportOperation, UpdateAgentSummaryRequest, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageBucket, UsageOperationBreakdown, UsageOutcomeBreakdown, UsageOutcomeGroup, UsageQuery,
|
||||
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||
WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, YamlImportJob,
|
||||
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
};
|
||||
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
mod admin_auth_lifecycle_v8;
|
||||
mod agent_catalog_lifecycle_v9;
|
||||
mod approval_side_effects_v10;
|
||||
mod authority;
|
||||
mod baseline_v1;
|
||||
mod execution_outcome_v5;
|
||||
mod master_key_identity_v7;
|
||||
mod onboarding_product_events_v11;
|
||||
mod owned_relations;
|
||||
mod platform_key_name_reuse_v6;
|
||||
mod schema_guard;
|
||||
mod schema_guard_v10;
|
||||
mod schema_guard_v11;
|
||||
mod schema_guard_v7;
|
||||
mod schema_guard_v8;
|
||||
mod schema_guard_v9;
|
||||
|
||||
pub use authority::{
|
||||
BackfillBatch, BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationDescriptor,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Postgres, Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("admin_auth_lifecycle_v8.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"6361f3321a1442a77c695cad9b30c4702de1aa3e565bfbf1a7415d653302611f";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.admin_auth_lifecycle",
|
||||
Some(8),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(8),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
create table admin_bootstrap_contracts (
|
||||
id text primary key,
|
||||
token_hash text not null unique,
|
||||
email text not null,
|
||||
display_name text not null,
|
||||
status text not null,
|
||||
attempts integer not null default 0,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null default now(),
|
||||
used_at timestamptz,
|
||||
used_by_user_id text references users(id),
|
||||
constraint admin_bootstrap_contracts_id_check check (
|
||||
octet_length(id) between 1 and 128
|
||||
and id !~ '[[:cntrl:]]'
|
||||
),
|
||||
constraint admin_bootstrap_contracts_token_hash_check check (
|
||||
token_hash ~ '^[A-Za-z0-9_-]{43,86}$'
|
||||
),
|
||||
constraint admin_bootstrap_contracts_email_check check (
|
||||
octet_length(email) between 3 and 254
|
||||
and email like '%@%'
|
||||
and email !~ '[[:space:][:cntrl:]<>]'
|
||||
),
|
||||
constraint admin_bootstrap_contracts_display_name_check check (
|
||||
octet_length(display_name) between 1 and 160
|
||||
and display_name !~ '[[:cntrl:]<>]'
|
||||
),
|
||||
constraint admin_bootstrap_contracts_status_check check (
|
||||
status in ('active', 'used', 'expired', 'revoked')
|
||||
),
|
||||
constraint admin_bootstrap_contracts_attempts_check check (
|
||||
attempts between 0 and 100
|
||||
),
|
||||
constraint admin_bootstrap_contracts_used_shape_check check (
|
||||
(status = 'used' and used_at is not null and used_by_user_id is not null)
|
||||
or (status <> 'used' and used_by_user_id is null)
|
||||
)
|
||||
);
|
||||
|
||||
create unique index admin_bootstrap_contracts_single_active_idx
|
||||
on admin_bootstrap_contracts (status)
|
||||
where status = 'active';
|
||||
|
||||
create index admin_bootstrap_contracts_token_hash_idx
|
||||
on admin_bootstrap_contracts (token_hash);
|
||||
|
||||
alter table user_sessions
|
||||
add column csrf_hash text,
|
||||
add column revoked_at timestamptz,
|
||||
add constraint user_sessions_csrf_hash_check check (
|
||||
csrf_hash is null or csrf_hash ~ '^[A-Za-z0-9_-]{43,86}$'
|
||||
);
|
||||
|
||||
create table admin_login_backoff (
|
||||
scope_hash text primary key,
|
||||
failure_count integer not null default 0,
|
||||
locked_until timestamptz,
|
||||
last_attempt_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint admin_login_backoff_scope_hash_check check (
|
||||
scope_hash ~ '^[A-Za-z0-9_-]{43,86}$'
|
||||
),
|
||||
constraint admin_login_backoff_failure_count_check check (
|
||||
failure_count between 0 and 1000
|
||||
)
|
||||
);
|
||||
|
||||
create table admin_security_audit_events (
|
||||
id text primary key,
|
||||
action text not null,
|
||||
outcome text not null,
|
||||
actor_user_id text references users(id),
|
||||
session_id text,
|
||||
request_id text,
|
||||
trace_id text,
|
||||
source text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint admin_security_audit_events_id_check check (
|
||||
octet_length(id) between 1 and 128
|
||||
and id !~ '[[:cntrl:]]'
|
||||
),
|
||||
constraint admin_security_audit_events_action_check check (
|
||||
action in (
|
||||
'bootstrap_created',
|
||||
'bootstrap_completed',
|
||||
'bootstrap_rejected',
|
||||
'login_succeeded',
|
||||
'login_rejected',
|
||||
'logout',
|
||||
'password_rotated',
|
||||
'recovery_completed',
|
||||
'session_revoked'
|
||||
)
|
||||
),
|
||||
constraint admin_security_audit_events_outcome_check check (
|
||||
outcome in ('success', 'rejected', 'rate_limited', 'expired', 'conflict')
|
||||
),
|
||||
constraint admin_security_audit_events_session_id_check check (
|
||||
session_id is null
|
||||
or (
|
||||
octet_length(session_id) between 1 and 128
|
||||
and session_id !~ '[[:cntrl:]]'
|
||||
)
|
||||
),
|
||||
constraint admin_security_audit_events_request_id_check check (
|
||||
request_id is null
|
||||
or (
|
||||
octet_length(request_id) between 1 and 128
|
||||
and request_id !~ '[[:cntrl:],;]'
|
||||
)
|
||||
),
|
||||
constraint admin_security_audit_events_trace_id_check check (
|
||||
trace_id is null or trace_id ~ '^[0-9a-f]{32}$'
|
||||
),
|
||||
constraint admin_security_audit_events_source_check check (
|
||||
octet_length(source) between 1 and 128
|
||||
and source !~ '[[:cntrl:]]'
|
||||
)
|
||||
);
|
||||
|
||||
create index admin_security_audit_events_created_idx
|
||||
on admin_security_audit_events (created_at desc);
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Postgres, Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("agent_catalog_lifecycle_v9.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"b49bf4b53691407ec27e3810b0531c0d440da7dddc62ea71eb219933c0a30211";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.agent_catalog_lifecycle",
|
||||
Some(9),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(9),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
alter table agents
|
||||
add column catalog_revision bigint not null default 0,
|
||||
add constraint agents_catalog_revision_check check (catalog_revision >= 0);
|
||||
|
||||
alter table published_agents
|
||||
add column catalog_revision bigint not null default 1,
|
||||
add constraint published_agents_catalog_revision_check check (catalog_revision > 0);
|
||||
|
||||
update agents a
|
||||
set catalog_revision = case when pa.agent_id is null then 0 else greatest(pa.catalog_revision, 1) end
|
||||
from agents source
|
||||
left join published_agents pa on pa.agent_id = source.id
|
||||
where a.id = source.id;
|
||||
|
||||
create function crank_reject_published_agent_version_mutation()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if old.status = 'published' then
|
||||
if tg_op = 'DELETE' then
|
||||
raise exception 'published Agent Version is immutable'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
if row(old.*) is distinct from row(new.*) then
|
||||
raise exception 'published Agent Version is immutable'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
end if;
|
||||
if tg_op = 'DELETE' then
|
||||
return old;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger agent_versions_immutable_guard
|
||||
before update or delete on agent_versions
|
||||
for each row
|
||||
execute function crank_reject_published_agent_version_mutation();
|
||||
|
||||
create function crank_reject_published_agent_binding_mutation()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
bound_status text;
|
||||
checked_agent_id text;
|
||||
checked_agent_version integer;
|
||||
begin
|
||||
checked_agent_id := coalesce(new.agent_id, old.agent_id);
|
||||
checked_agent_version := coalesce(new.agent_version, old.agent_version);
|
||||
select status
|
||||
into bound_status
|
||||
from agent_versions
|
||||
where agent_id = checked_agent_id and version = checked_agent_version;
|
||||
if bound_status = 'published' then
|
||||
raise exception 'published Agent catalog bindings are immutable'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
if tg_op = 'DELETE' then
|
||||
return old;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger agent_operation_bindings_immutable_guard
|
||||
before insert or update or delete on agent_operation_bindings
|
||||
for each row
|
||||
execute function crank_reject_published_agent_binding_mutation();
|
||||
|
||||
create function crank_reject_published_agent_pointer_rewind()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
raise exception 'published Agent catalog pointer is immutable'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
if tg_op = 'INSERT' then
|
||||
return new;
|
||||
end if;
|
||||
if new.catalog_revision <= old.catalog_revision then
|
||||
raise exception 'published Agent catalog revision must increase'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
if new.version < old.version then
|
||||
raise exception 'published Agent catalog version cannot rewind'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger published_agents_monotonic_guard
|
||||
before insert or update or delete on published_agents
|
||||
for each row
|
||||
execute function crank_reject_published_agent_pointer_rewind();
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Postgres, Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("approval_side_effects_v10.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"6c87f680efdbc275fa2b920826b3e3e390aa34d6f2f1db48fe074a5d7691ba5b";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.approval_side_effects",
|
||||
Some(10),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(10),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
alter table approval_requests
|
||||
add column if not exists request_id text null;
|
||||
|
||||
alter table approval_requests
|
||||
add column if not exists trace_id text null;
|
||||
|
||||
alter table approval_requests
|
||||
add constraint approval_requests_request_id_check check (
|
||||
request_id is null
|
||||
or (
|
||||
octet_length(request_id) between 1 and 128
|
||||
and request_id !~ '[[:cntrl:],;]'
|
||||
)
|
||||
) not valid;
|
||||
|
||||
alter table approval_requests
|
||||
add constraint approval_requests_trace_id_check check (
|
||||
trace_id is null
|
||||
or (
|
||||
trace_id ~ '^[0-9a-f]{32}$'
|
||||
and trace_id <> '00000000000000000000000000000000'
|
||||
)
|
||||
) not valid;
|
||||
|
||||
create unique index if not exists approval_requests_pending_scope_fingerprint_idx
|
||||
on approval_requests(
|
||||
workspace_id,
|
||||
agent_id,
|
||||
operation_id,
|
||||
operation_version,
|
||||
request_fingerprint
|
||||
)
|
||||
where status = 'pending' and request_fingerprint is not null;
|
||||
|
||||
create index if not exists approval_requests_workspace_request_trace_idx
|
||||
on approval_requests(workspace_id, request_id, trace_id)
|
||||
where request_id is not null or trace_id is not null;
|
||||
@@ -1,17 +1,22 @@
|
||||
use std::fmt;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
|
||||
|
||||
use super::admin_auth_lifecycle_v8;
|
||||
use super::agent_catalog_lifecycle_v9;
|
||||
use super::approval_side_effects_v10;
|
||||
use super::execution_outcome_v5;
|
||||
use super::master_key_identity_v7;
|
||||
use super::onboarding_product_events_v11;
|
||||
use super::owned_relations;
|
||||
use super::platform_key_name_reuse_v6;
|
||||
use super::schema_guard::{
|
||||
OWNED_RELATIONS, relation_exists, validate_required_relations, validate_schema_fingerprint,
|
||||
};
|
||||
use super::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline};
|
||||
use crate::ext::ExtensionMigration;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
|
||||
use std::fmt;
|
||||
const MIGRATION_LOCK_ID: i64 = 0x4352_414E_4B4D_4947;
|
||||
const CURRENT_VERSION: i64 = 3;
|
||||
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3];
|
||||
const CURRENT_VERSION: i64 = 11;
|
||||
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
const BASELINE_SOURCE_SHA256: &str =
|
||||
"eb1656fc5b4b5be9ee390d237d1d58e4b2274ae5ba9b7ba06a2f3f860dfda675";
|
||||
const CONSOLIDATION_SOURCE: &str = include_str!("consolidation_v2.sql");
|
||||
@@ -20,41 +25,10 @@ const CONSOLIDATION_SOURCE_SHA256: &str =
|
||||
const REQUEST_TRACE_IDENTITY_SOURCE: &str = include_str!("request_trace_identity_v3.sql");
|
||||
const REQUEST_TRACE_IDENTITY_SOURCE_SHA256: &str =
|
||||
"36487625503a8d4d8f18d5771c3c9b6705f845e267acbfcb7244c330f640cd94";
|
||||
const BASELINE_RELATIONS: &[&str] = &[
|
||||
"workspaces",
|
||||
"users",
|
||||
"memberships",
|
||||
"user_sessions",
|
||||
"invitation_tokens",
|
||||
"platform_api_keys",
|
||||
"operations",
|
||||
"operation_versions",
|
||||
"published_operations",
|
||||
"operation_samples",
|
||||
"descriptors",
|
||||
"agents",
|
||||
"agent_versions",
|
||||
"published_agents",
|
||||
"agent_operation_bindings",
|
||||
"secrets",
|
||||
"secret_versions",
|
||||
"auth_profiles",
|
||||
"workspace_upstreams",
|
||||
"yaml_import_jobs",
|
||||
"import_jobs",
|
||||
"approval_requests",
|
||||
"invocation_logs",
|
||||
"usage_rollups",
|
||||
];
|
||||
const CONSOLIDATION_RELATIONS: &[&str] = &[
|
||||
"__crank_migrations",
|
||||
"__crank_migration_legacy_audit",
|
||||
"__crank_mcp_migrations",
|
||||
"mcp_transport_sessions",
|
||||
"__crank_ext_migrations",
|
||||
];
|
||||
const OPERATION_LIFECYCLE_SOURCE: &str = include_str!("operation_lifecycle_v4.sql");
|
||||
const OPERATION_LIFECYCLE_SOURCE_SHA256: &str =
|
||||
"45723712a1ea49cd8bbf59d77148c225f3ec376df7433983d982cc6f9d7fb39c";
|
||||
const REGISTERED_EXTENSION_MIGRATIONS: &[(&str, ExtensionMigration)] = &[];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MigrationDescriptor {
|
||||
pub version: i64,
|
||||
@@ -70,7 +44,6 @@ pub struct MigrationDescriptor {
|
||||
pub readable_schema_max: i64,
|
||||
pub contract_evidence: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BackfillPolicy {
|
||||
None,
|
||||
@@ -80,14 +53,12 @@ pub enum BackfillPolicy {
|
||||
resumable: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BackfillBatch {
|
||||
pub cursor: Option<String>,
|
||||
pub max_rows: u32,
|
||||
pub max_ms: u32,
|
||||
}
|
||||
|
||||
impl BackfillBatch {
|
||||
pub fn validate(&self, policy: BackfillPolicy) -> Result<(), MigrationError> {
|
||||
match policy {
|
||||
@@ -113,19 +84,16 @@ impl BackfillBatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MigrationPreflight {
|
||||
Current { version: i64 },
|
||||
MigrationRequired { current: i64, target: i64 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MigrationApplyResult {
|
||||
Applied { from: i64, to: i64 },
|
||||
AlreadyCurrent { version: i64 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MigrationError {
|
||||
code: &'static str,
|
||||
@@ -133,7 +101,6 @@ pub struct MigrationError {
|
||||
version: Option<i64>,
|
||||
recovery: &'static str,
|
||||
}
|
||||
|
||||
impl MigrationError {
|
||||
pub(super) fn new(
|
||||
code: &'static str,
|
||||
@@ -148,28 +115,22 @@ impl MigrationError {
|
||||
recovery,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn storage(stage: &'static str) -> Self {
|
||||
Self::new("storage_unavailable", stage, None, "contact_operator")
|
||||
}
|
||||
|
||||
pub fn code(&self) -> &'static str {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn stage(&self) -> &'static str {
|
||||
self.stage
|
||||
}
|
||||
|
||||
pub fn version(&self) -> Option<i64> {
|
||||
self.version
|
||||
}
|
||||
|
||||
pub fn recovery(&self) -> &'static str {
|
||||
self.recovery
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MigrationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
@@ -183,16 +144,33 @@ impl fmt::Display for MigrationError {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MigrationError {}
|
||||
|
||||
pub struct MigrationAuthority;
|
||||
|
||||
fn expand_descriptor(
|
||||
version: i64,
|
||||
name: &'static str,
|
||||
checksum: &'static str,
|
||||
readable_schema_min: i64,
|
||||
) -> MigrationDescriptor {
|
||||
MigrationDescriptor {
|
||||
version,
|
||||
name,
|
||||
checksum: checksum.to_owned(),
|
||||
source_digest: checksum.to_owned(),
|
||||
phase: "expand",
|
||||
compatibility: "n-minus-one-readable",
|
||||
owner: "crank-registry",
|
||||
transactional: true,
|
||||
backfill: BackfillPolicy::None,
|
||||
readable_schema_min,
|
||||
readable_schema_max: version,
|
||||
contract_evidence: None,
|
||||
}
|
||||
}
|
||||
impl MigrationAuthority {
|
||||
pub fn registered_extension_migrations() -> &'static [(&'static str, ExtensionMigration)] {
|
||||
REGISTERED_EXTENSION_MIGRATIONS
|
||||
}
|
||||
|
||||
pub fn sequence() -> Vec<MigrationDescriptor> {
|
||||
vec![
|
||||
MigrationDescriptor {
|
||||
@@ -209,41 +187,66 @@ impl MigrationAuthority {
|
||||
readable_schema_max: 1,
|
||||
contract_evidence: None,
|
||||
},
|
||||
MigrationDescriptor {
|
||||
version: 2,
|
||||
name: "legacy-consolidation-v2",
|
||||
checksum: CONSOLIDATION_SOURCE_SHA256.to_owned(),
|
||||
source_digest: CONSOLIDATION_SOURCE_SHA256.to_owned(),
|
||||
phase: "expand",
|
||||
compatibility: "n-minus-one-readable",
|
||||
owner: "crank-registry",
|
||||
transactional: true,
|
||||
backfill: BackfillPolicy::None,
|
||||
readable_schema_min: 1,
|
||||
readable_schema_max: 2,
|
||||
contract_evidence: None,
|
||||
},
|
||||
MigrationDescriptor {
|
||||
version: 3,
|
||||
name: "request-trace-identity-v3",
|
||||
checksum: REQUEST_TRACE_IDENTITY_SOURCE_SHA256.to_owned(),
|
||||
source_digest: REQUEST_TRACE_IDENTITY_SOURCE_SHA256.to_owned(),
|
||||
phase: "expand",
|
||||
compatibility: "n-minus-one-readable",
|
||||
owner: "crank-registry",
|
||||
transactional: true,
|
||||
backfill: BackfillPolicy::None,
|
||||
readable_schema_min: 2,
|
||||
readable_schema_max: 3,
|
||||
contract_evidence: None,
|
||||
},
|
||||
expand_descriptor(2, "legacy-consolidation-v2", CONSOLIDATION_SOURCE_SHA256, 1),
|
||||
expand_descriptor(
|
||||
3,
|
||||
"request-trace-identity-v3",
|
||||
REQUEST_TRACE_IDENTITY_SOURCE_SHA256,
|
||||
2,
|
||||
),
|
||||
expand_descriptor(
|
||||
4,
|
||||
"operation-lifecycle-v4",
|
||||
OPERATION_LIFECYCLE_SOURCE_SHA256,
|
||||
3,
|
||||
),
|
||||
expand_descriptor(
|
||||
5,
|
||||
"execution-outcome-v5",
|
||||
execution_outcome_v5::SOURCE_SHA256,
|
||||
4,
|
||||
),
|
||||
expand_descriptor(
|
||||
6,
|
||||
"platform-key-name-reuse-v6",
|
||||
platform_key_name_reuse_v6::SOURCE_SHA256,
|
||||
5,
|
||||
),
|
||||
expand_descriptor(
|
||||
7,
|
||||
"master-key-identity-v7",
|
||||
master_key_identity_v7::SOURCE_SHA256,
|
||||
6,
|
||||
),
|
||||
expand_descriptor(
|
||||
8,
|
||||
"admin-auth-lifecycle-v8",
|
||||
admin_auth_lifecycle_v8::SOURCE_SHA256,
|
||||
7,
|
||||
),
|
||||
expand_descriptor(
|
||||
9,
|
||||
"agent-catalog-lifecycle-v9",
|
||||
agent_catalog_lifecycle_v9::SOURCE_SHA256,
|
||||
8,
|
||||
),
|
||||
expand_descriptor(
|
||||
10,
|
||||
"approval-side-effects-v10",
|
||||
approval_side_effects_v10::SOURCE_SHA256,
|
||||
9,
|
||||
),
|
||||
expand_descriptor(
|
||||
11,
|
||||
"onboarding-product-events-v11",
|
||||
onboarding_product_events_v11::SOURCE_SHA256,
|
||||
10,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn validate_sequence() -> Result<(), MigrationError> {
|
||||
validate_descriptors(&Self::sequence())
|
||||
}
|
||||
|
||||
pub async fn preflight(pool: &PgPool) -> Result<MigrationPreflight, MigrationError> {
|
||||
Self::validate_sequence()?;
|
||||
let mut connection = pool
|
||||
@@ -252,7 +255,6 @@ impl MigrationAuthority {
|
||||
.map_err(|_| MigrationError::storage("preflight.connect"))?;
|
||||
inspect(&mut connection).await
|
||||
}
|
||||
|
||||
pub async fn require_current(pool: &PgPool) -> Result<(), MigrationError> {
|
||||
match Self::preflight(pool).await? {
|
||||
MigrationPreflight::Current { .. } => Ok(()),
|
||||
@@ -270,7 +272,6 @@ impl MigrationAuthority {
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply(pool: &PgPool) -> Result<MigrationApplyResult, MigrationError> {
|
||||
Self::validate_sequence()?;
|
||||
let mut transaction = pool
|
||||
@@ -296,7 +297,6 @@ impl MigrationAuthority {
|
||||
MigrationError::storage("apply.lock")
|
||||
}
|
||||
})?;
|
||||
|
||||
let before = inspect(&mut transaction).await?;
|
||||
let from = match before {
|
||||
MigrationPreflight::Current { version } => {
|
||||
@@ -308,7 +308,6 @@ impl MigrationAuthority {
|
||||
}
|
||||
MigrationPreflight::MigrationRequired { current, .. } => current,
|
||||
};
|
||||
|
||||
if from == 0 {
|
||||
create_core_ledger(&mut transaction).await?;
|
||||
apply_baseline(&mut transaction).await.map_err(|_| {
|
||||
@@ -335,14 +334,36 @@ impl MigrationAuthority {
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if from < 2 {
|
||||
apply_consolidation(&mut transaction).await?;
|
||||
}
|
||||
if from < 3 {
|
||||
apply_request_trace_identity(&mut transaction).await?;
|
||||
}
|
||||
|
||||
if from < 4 {
|
||||
apply_operation_lifecycle(&mut transaction).await?;
|
||||
}
|
||||
if from < 5 {
|
||||
execution_outcome_v5::apply(&mut transaction, &Self::sequence()[4]).await?;
|
||||
}
|
||||
if from < 6 {
|
||||
platform_key_name_reuse_v6::apply(&mut transaction, &Self::sequence()[5]).await?;
|
||||
}
|
||||
if from < 7 {
|
||||
master_key_identity_v7::apply(&mut transaction, &Self::sequence()[6]).await?;
|
||||
}
|
||||
if from < 8 {
|
||||
admin_auth_lifecycle_v8::apply(&mut transaction, &Self::sequence()[7]).await?;
|
||||
}
|
||||
if from < 9 {
|
||||
agent_catalog_lifecycle_v9::apply(&mut transaction, &Self::sequence()[8]).await?;
|
||||
}
|
||||
if from < 10 {
|
||||
approval_side_effects_v10::apply(&mut transaction, &Self::sequence()[9]).await?;
|
||||
}
|
||||
if from < 11 {
|
||||
onboarding_product_events_v11::apply(&mut transaction, &Self::sequence()[10]).await?;
|
||||
}
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
@@ -353,11 +374,9 @@ impl MigrationAuthority {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn baseline_source_digest() -> String {
|
||||
let source = include_str!("baseline_v1.rs");
|
||||
@@ -369,7 +388,6 @@ fn baseline_source_digest() -> String {
|
||||
.expect("baseline end marker must exist");
|
||||
sha256_hex(baseline.as_bytes())
|
||||
}
|
||||
|
||||
fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), MigrationError> {
|
||||
if descriptors.is_empty() || descriptors.len() > 1_024 {
|
||||
return Err(MigrationError::new(
|
||||
@@ -461,6 +479,19 @@ fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), Migra
|
||||
|| sha256_hex(CONSOLIDATION_SOURCE.as_bytes()) != CONSOLIDATION_SOURCE_SHA256
|
||||
|| sha256_hex(REQUEST_TRACE_IDENTITY_SOURCE.as_bytes())
|
||||
!= REQUEST_TRACE_IDENTITY_SOURCE_SHA256
|
||||
|| sha256_hex(OPERATION_LIFECYCLE_SOURCE.as_bytes()) != OPERATION_LIFECYCLE_SOURCE_SHA256
|
||||
|| sha256_hex(execution_outcome_v5::SOURCE.as_bytes())
|
||||
!= execution_outcome_v5::SOURCE_SHA256
|
||||
|| sha256_hex(platform_key_name_reuse_v6::SOURCE.as_bytes())
|
||||
!= platform_key_name_reuse_v6::SOURCE_SHA256
|
||||
|| sha256_hex(master_key_identity_v7::SOURCE.as_bytes())
|
||||
!= master_key_identity_v7::SOURCE_SHA256
|
||||
|| sha256_hex(admin_auth_lifecycle_v8::SOURCE.as_bytes())
|
||||
!= admin_auth_lifecycle_v8::SOURCE_SHA256
|
||||
|| sha256_hex(agent_catalog_lifecycle_v9::SOURCE.as_bytes())
|
||||
!= agent_catalog_lifecycle_v9::SOURCE_SHA256
|
||||
|| sha256_hex(approval_side_effects_v10::SOURCE.as_bytes())
|
||||
!= approval_side_effects_v10::SOURCE_SHA256
|
||||
{
|
||||
return Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
@@ -471,11 +502,9 @@ fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), Migra
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, MigrationError> {
|
||||
let core_exists = relation_exists(connection, "__crank_core_migrations").await?;
|
||||
let canonical_exists = relation_exists(connection, "__crank_migrations").await?;
|
||||
|
||||
if !core_exists {
|
||||
let mut owned_exists = canonical_exists;
|
||||
for relation in OWNED_RELATIONS {
|
||||
@@ -495,18 +524,15 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
|
||||
target: CURRENT_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
validate_core_ledger(connection).await?;
|
||||
validate_required_relations(connection, BASELINE_RELATIONS, 1).await?;
|
||||
validate_required_relations(connection, owned_relations::BASELINE, 1).await?;
|
||||
inspect_optional_legacy(connection).await?;
|
||||
|
||||
if !canonical_exists {
|
||||
return Ok(MigrationPreflight::MigrationRequired {
|
||||
current: 1,
|
||||
target: CURRENT_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
let descriptors = MigrationAuthority::sequence();
|
||||
let rows = query("select version, name, checksum, phase, compatibility from __crank_migrations order by version limit 1025")
|
||||
.fetch_all(&mut *connection)
|
||||
@@ -580,7 +606,6 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let current = rows
|
||||
.last()
|
||||
.and_then(|row| row.try_get::<i64, _>("version").ok())
|
||||
@@ -600,13 +625,19 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
|
||||
target: CURRENT_VERSION,
|
||||
})
|
||||
} else {
|
||||
validate_required_relations(connection, CONSOLIDATION_RELATIONS, CURRENT_VERSION).await?;
|
||||
validate_required_relations(connection, owned_relations::CONSOLIDATION, CURRENT_VERSION)
|
||||
.await?;
|
||||
validate_required_relations(
|
||||
connection,
|
||||
owned_relations::ONBOARDING_PRODUCT_EVENTS,
|
||||
CURRENT_VERSION,
|
||||
)
|
||||
.await?;
|
||||
validate_schema_fingerprint(connection, CURRENT_VERSION).await?;
|
||||
validate_legacy_audit(connection).await?;
|
||||
Ok(MigrationPreflight::Current { version: current })
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let rows = query("select version, description, checksum from __crank_core_migrations order by version limit 2")
|
||||
.fetch_all(connection)
|
||||
@@ -642,7 +673,6 @@ async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), Migra
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?;
|
||||
let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?;
|
||||
@@ -749,7 +779,6 @@ async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), Mi
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_legacy_audit(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let rows = query(
|
||||
"select source, source_version, source_checksum
|
||||
@@ -800,7 +829,6 @@ async fn validate_legacy_audit(connection: &mut PgConnection) -> Result<(), Migr
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_core_ledger(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), MigrationError> {
|
||||
@@ -824,7 +852,6 @@ async fn create_core_ledger(
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_consolidation(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), MigrationError> {
|
||||
@@ -883,7 +910,6 @@ async fn apply_consolidation(
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_request_trace_identity(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), MigrationError> {
|
||||
@@ -920,7 +946,42 @@ async fn apply_request_trace_identity(
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_operation_lifecycle(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(OPERATION_LIFECYCLE_SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.operation_lifecycle",
|
||||
Some(4),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
let descriptor = &MigrationAuthority::sequence()[3];
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(4),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "authority_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -8,7 +8,7 @@ fn sequence_is_deterministic_and_append_only() {
|
||||
MigrationAuthority::validate_sequence().unwrap();
|
||||
assert_eq!(
|
||||
first.iter().map(|item| item.version).collect::<Vec<_>>(),
|
||||
vec![1, 2, 3]
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
||||
);
|
||||
assert_eq!(first[0].checksum, "crank-community-baseline-v1");
|
||||
assert_eq!(first[0].source_digest, BASELINE_SOURCE_SHA256);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("execution_outcome_v5.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"bd6703249cc407789586327eb7fbbd5776ba27923c5dc7eb85b05d332585bf42";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.execution_outcome",
|
||||
Some(5),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(5),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
alter table invocation_logs
|
||||
add column operation_version integer null,
|
||||
add column execution_stage text null,
|
||||
add column execution_error_code text null,
|
||||
add column retryability text null,
|
||||
add column outcome_certainty text null;
|
||||
|
||||
alter table invocation_logs
|
||||
add constraint invocation_logs_operation_version_check
|
||||
check (operation_version is null or operation_version > 0),
|
||||
add constraint invocation_logs_execution_stage_check
|
||||
check (execution_stage is null or execution_stage in (
|
||||
'authorization', 'input_schema', 'input_mapping', 'request_preparation',
|
||||
'admission', 'adapter', 'upstream', 'output_mapping', 'output_schema',
|
||||
'mandatory_persistence', 'runtime'
|
||||
)),
|
||||
add constraint invocation_logs_execution_error_code_check
|
||||
check (execution_error_code is null or execution_error_code in (
|
||||
'authorization_denied', 'auth_profile_not_found', 'secret_not_found',
|
||||
'secret_invalid', 'input_schema_invalid', 'input_mapping_invalid',
|
||||
'prepared_request_invalid', 'execution_overloaded', 'safety_store_unavailable',
|
||||
'protocol_unsupported', 'execution_mode_unsupported',
|
||||
'adapter_configuration_invalid', 'outbound_target_rejected',
|
||||
'upstream_auth_error', 'upstream_not_found', 'upstream_rate_limited',
|
||||
'upstream_server_error', 'upstream_status_error', 'upstream_timeout',
|
||||
'upstream_transport_error', 'upstream_request_too_large',
|
||||
'upstream_response_too_large',
|
||||
'output_mapping_invalid', 'output_schema_invalid', 'persistence_unavailable',
|
||||
'runtime_internal', 'confirmation_required', 'confirmation_invalid',
|
||||
'idempotency_in_progress', 'idempotency_conflict', 'idempotency_outcome_unknown'
|
||||
)),
|
||||
add constraint invocation_logs_retryability_check
|
||||
check (retryability is null or retryability in (
|
||||
'never', 'safe', 'after_delay', 'manual_reconcile', 'requires_confirmation'
|
||||
)),
|
||||
add constraint invocation_logs_outcome_certainty_check
|
||||
check (outcome_certainty is null or outcome_certainty in ('certain', 'outcome_unknown'));
|
||||
|
||||
create index invocation_logs_workspace_operation_version_idx
|
||||
on invocation_logs(workspace_id, operation_id, operation_version)
|
||||
where operation_version is not null;
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("master_key_identity_v7.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"06e5d4667d9a7346474e9dcb176b8b2b9680c4b89089bea5a18eb5a88d5ac60e";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.master_key_identity",
|
||||
Some(7),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(7),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
create table master_key_identities (
|
||||
epoch bigint primary key,
|
||||
fingerprint text not null unique,
|
||||
cipher_contract text not null,
|
||||
status text not null,
|
||||
backup_ref text,
|
||||
created_at timestamptz not null default now(),
|
||||
activated_at timestamptz,
|
||||
retired_at timestamptz,
|
||||
constraint master_key_identities_epoch_check check (epoch > 0),
|
||||
constraint master_key_identities_fingerprint_check check (
|
||||
fingerprint ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
constraint master_key_identities_cipher_contract_check check (
|
||||
cipher_contract = 'secret-envelope-v2/aes-256-gcm-hkdf-sha256'
|
||||
),
|
||||
constraint master_key_identities_status_check check (
|
||||
status in ('active', 'pending', 'retired', 'revoked')
|
||||
),
|
||||
constraint master_key_identities_backup_ref_check check (
|
||||
backup_ref is null
|
||||
or (
|
||||
octet_length(backup_ref) <= 256
|
||||
and backup_ref !~ '[[:cntrl:]]'
|
||||
and backup_ref !~ '^[A-Za-z][A-Za-z0-9+.-]*://'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create unique index master_key_identities_active_idx
|
||||
on master_key_identities (status)
|
||||
where status = 'active';
|
||||
|
||||
create table master_key_rotations (
|
||||
id text primary key,
|
||||
source_epoch bigint not null,
|
||||
target_epoch bigint not null,
|
||||
target_fingerprint text not null,
|
||||
state text not null,
|
||||
backup_ref text,
|
||||
checkpoint_secret_id text,
|
||||
total_secret_versions bigint not null default 0,
|
||||
processed_secret_versions bigint not null default 0,
|
||||
verified_secret_versions bigint not null default 0,
|
||||
failure_code text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint master_key_rotations_id_check check (
|
||||
octet_length(id) between 1 and 128
|
||||
and id !~ '[[:cntrl:]]'
|
||||
),
|
||||
constraint master_key_rotations_source_epoch_check check (source_epoch > 0),
|
||||
constraint master_key_rotations_target_epoch_check check (target_epoch > source_epoch),
|
||||
constraint master_key_rotations_target_fingerprint_check check (
|
||||
target_fingerprint ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
constraint master_key_rotations_state_check check (
|
||||
state in ('preflighted', 'running', 'verifying', 'verified', 'promoted', 'aborted', 'failed')
|
||||
),
|
||||
constraint master_key_rotations_backup_ref_check check (
|
||||
backup_ref is null
|
||||
or (
|
||||
octet_length(backup_ref) <= 256
|
||||
and backup_ref !~ '[[:cntrl:]]'
|
||||
and backup_ref !~ '^[A-Za-z][A-Za-z0-9+.-]*://'
|
||||
)
|
||||
),
|
||||
constraint master_key_rotations_counts_check check (
|
||||
total_secret_versions >= 0
|
||||
and processed_secret_versions >= 0
|
||||
and verified_secret_versions >= 0
|
||||
and processed_secret_versions <= total_secret_versions
|
||||
and verified_secret_versions <= total_secret_versions
|
||||
),
|
||||
constraint master_key_rotations_failure_code_check check (
|
||||
failure_code is null
|
||||
or (
|
||||
octet_length(failure_code) between 1 and 128
|
||||
and failure_code !~ '[[:cntrl:]]'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
alter table secret_versions
|
||||
add column master_key_epoch bigint not null default 1,
|
||||
add column target_ciphertext text,
|
||||
add column target_key_version text,
|
||||
add column target_master_key_epoch bigint,
|
||||
add constraint secret_versions_master_key_epoch_check check (master_key_epoch > 0),
|
||||
add constraint secret_versions_target_epoch_check check (
|
||||
target_master_key_epoch is null or target_master_key_epoch > master_key_epoch
|
||||
),
|
||||
add constraint secret_versions_target_all_or_none_check check (
|
||||
(
|
||||
target_ciphertext is null
|
||||
and target_key_version is null
|
||||
and target_master_key_epoch is null
|
||||
)
|
||||
or (
|
||||
target_ciphertext is not null
|
||||
and target_key_version is not null
|
||||
and target_master_key_epoch is not null
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Postgres, Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("onboarding_product_events_v11.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"a439bbcdc9cc909d717ed5a868c7a51bd1ad3c49c4e85fd20933743ee3f31166";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.onboarding_product_events",
|
||||
Some(11),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(11),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
alter table platform_api_keys
|
||||
add constraint platform_api_keys_workspace_agent_id_unique
|
||||
unique (workspace_id, agent_id, id);
|
||||
|
||||
alter table invocation_logs
|
||||
add column platform_api_key_id text null;
|
||||
|
||||
alter table invocation_logs
|
||||
add constraint invocation_logs_platform_key_scope_fk
|
||||
foreign key (workspace_id, agent_id, platform_api_key_id)
|
||||
references platform_api_keys(workspace_id, agent_id, id)
|
||||
on delete set null (platform_api_key_id);
|
||||
|
||||
create index invocation_logs_workspace_agent_key_success_idx
|
||||
on invocation_logs(workspace_id, agent_id, platform_api_key_id, created_at desc)
|
||||
where platform_api_key_id is not null
|
||||
and source = 'agent_tool_call'
|
||||
and status = 'ok';
|
||||
|
||||
create table product_events (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
event_name text not null,
|
||||
schema_version integer not null,
|
||||
occurred_at timestamptz not null,
|
||||
idempotency_key text not null,
|
||||
properties_json jsonb not null default '{}'::jsonb,
|
||||
constraint product_events_id_check check (id ~ '^pe_[A-Za-z0-9_-]{1,128}$'),
|
||||
constraint product_events_name_check check (event_name in (
|
||||
'onboarding_eligible', 'onboarding_started', 'onboarding_resumed', 'onboarding_dismissed',
|
||||
'onboarding_abandoned', 'onboarding_completed'
|
||||
)),
|
||||
constraint product_events_schema_version_check check (schema_version = 1),
|
||||
constraint product_events_idempotency_key_check check (
|
||||
octet_length(idempotency_key) between 1 and 256
|
||||
),
|
||||
constraint product_events_properties_check check (
|
||||
jsonb_typeof(properties_json) = 'object'
|
||||
and pg_column_size(properties_json) <= 4096
|
||||
and (
|
||||
event_name <> 'onboarding_eligible'
|
||||
or (
|
||||
properties_json @> '{"eligible": true}'::jsonb
|
||||
and jsonb_typeof(properties_json -> 'eligible_since') = 'string'
|
||||
)
|
||||
)
|
||||
),
|
||||
unique (workspace_id, idempotency_key)
|
||||
);
|
||||
|
||||
create index product_events_workspace_occurred_idx
|
||||
on product_events(workspace_id, occurred_at, id);
|
||||
|
||||
create table product_event_daily_rollups (
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
event_name text not null,
|
||||
day date not null,
|
||||
events_total bigint not null default 0,
|
||||
eligible_total bigint not null default 0,
|
||||
constraint product_event_daily_rollups_name_check check (event_name in (
|
||||
'onboarding_eligible', 'onboarding_started', 'onboarding_resumed', 'onboarding_dismissed',
|
||||
'onboarding_abandoned', 'onboarding_completed'
|
||||
)),
|
||||
constraint product_event_daily_rollups_counts_check check (
|
||||
events_total >= 0 and eligible_total >= 0 and eligible_total <= events_total
|
||||
),
|
||||
primary key (workspace_id, event_name, day)
|
||||
);
|
||||
|
||||
create table onboarding_selections (
|
||||
workspace_id text primary key references workspaces(id) on delete cascade,
|
||||
operation_id text null,
|
||||
operation_version integer null,
|
||||
agent_id text null,
|
||||
catalog_revision bigint null,
|
||||
platform_api_key_id text null,
|
||||
invocation_log_id text null,
|
||||
test_log_id text null,
|
||||
evidence_after timestamptz not null,
|
||||
selected_at timestamptz null,
|
||||
constraint onboarding_selections_shape_check check (
|
||||
(
|
||||
operation_id is null and operation_version is null and agent_id is null
|
||||
and catalog_revision is null and platform_api_key_id is null
|
||||
and invocation_log_id is null and test_log_id is null and selected_at is null
|
||||
) or (
|
||||
operation_id is not null and operation_version is not null and operation_version > 0
|
||||
and agent_id is not null and catalog_revision is not null and catalog_revision > 0
|
||||
and platform_api_key_id is not null and invocation_log_id is not null
|
||||
and selected_at is not null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create function crank_reject_product_event_mutation()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if tg_op = 'DELETE'
|
||||
and not exists (select 1 from workspaces where id = old.workspace_id) then
|
||||
return old;
|
||||
end if;
|
||||
raise exception 'ProductEvent is append-only' using errcode = '23514';
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger product_events_append_only_guard
|
||||
before update or delete on product_events
|
||||
for each row execute function crank_reject_product_event_mutation();
|
||||
@@ -0,0 +1,142 @@
|
||||
alter table operation_versions
|
||||
add column name text,
|
||||
add column display_name text,
|
||||
add column category text,
|
||||
add column protocol text,
|
||||
add column security_level text,
|
||||
add column snapshot_provenance text,
|
||||
add column snapshot_observed_at timestamptz,
|
||||
add column published_at timestamptz,
|
||||
add column published_by text;
|
||||
|
||||
update operation_versions ov
|
||||
set name = o.name,
|
||||
display_name = o.display_name,
|
||||
category = o.category,
|
||||
protocol = o.protocol,
|
||||
security_level = o.security_level,
|
||||
snapshot_provenance = 'legacy_observed',
|
||||
snapshot_observed_at = statement_timestamp()
|
||||
from operations o
|
||||
where o.id = ov.operation_id;
|
||||
|
||||
update operation_versions ov
|
||||
set published_at = po.published_at,
|
||||
published_by = po.published_by
|
||||
from published_operations po
|
||||
where po.operation_id = ov.operation_id
|
||||
and po.version = ov.version;
|
||||
|
||||
alter table operation_versions
|
||||
alter column name set not null,
|
||||
alter column display_name set not null,
|
||||
alter column category set not null,
|
||||
alter column protocol set not null,
|
||||
alter column security_level set not null,
|
||||
alter column snapshot_provenance set not null,
|
||||
alter column snapshot_observed_at set not null,
|
||||
add constraint operation_versions_snapshot_provenance_check
|
||||
check (snapshot_provenance in ('legacy_observed', 'native_v4'));
|
||||
|
||||
create or replace function crank_guard_operation_version_immutable()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $guard$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
if old.status = 'published' then
|
||||
raise exception 'published operation version is immutable'
|
||||
using errcode = '55000';
|
||||
end if;
|
||||
return old;
|
||||
end if;
|
||||
|
||||
if old.status = 'draft'
|
||||
and new.status = 'published'
|
||||
and old.operation_id = new.operation_id
|
||||
and old.version = new.version
|
||||
and old.name = new.name
|
||||
and old.display_name = new.display_name
|
||||
and old.category = new.category
|
||||
and old.protocol = new.protocol
|
||||
and old.security_level = new.security_level
|
||||
and old.target_json = new.target_json
|
||||
and old.input_schema_json = new.input_schema_json
|
||||
and old.output_schema_json = new.output_schema_json
|
||||
and old.input_mapping_json = new.input_mapping_json
|
||||
and old.output_mapping_json = new.output_mapping_json
|
||||
and old.execution_config_json = new.execution_config_json
|
||||
and old.tool_description_json = new.tool_description_json
|
||||
and old.samples_json is not distinct from new.samples_json
|
||||
and old.generated_draft_json is not distinct from new.generated_draft_json
|
||||
and old.config_export_json is not distinct from new.config_export_json
|
||||
and old.wizard_state_json is not distinct from new.wizard_state_json
|
||||
and old.change_note is not distinct from new.change_note
|
||||
and old.created_at = new.created_at
|
||||
and old.created_by is not distinct from new.created_by
|
||||
and old.snapshot_provenance = new.snapshot_provenance
|
||||
and old.snapshot_observed_at = new.snapshot_observed_at
|
||||
and old.published_at is null
|
||||
and new.published_at is not null
|
||||
then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
raise exception 'operation version is append-only'
|
||||
using errcode = '55000';
|
||||
end;
|
||||
$guard$;
|
||||
|
||||
create trigger operation_versions_immutable_guard
|
||||
before update or delete on operation_versions
|
||||
for each row execute function crank_guard_operation_version_immutable();
|
||||
|
||||
create or replace function crank_guard_published_operation_pointer()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $guard$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
raise exception 'published operation pointer is immutable'
|
||||
using errcode = '55000';
|
||||
end if;
|
||||
if new.operation_id = old.operation_id and new.version > old.version then
|
||||
return new;
|
||||
end if;
|
||||
raise exception 'published operation pointer cannot rewind'
|
||||
using errcode = '55000';
|
||||
end;
|
||||
$guard$;
|
||||
|
||||
create trigger published_operations_monotonic_guard
|
||||
before update or delete on published_operations
|
||||
for each row execute function crank_guard_published_operation_pointer();
|
||||
|
||||
create or replace function crank_guard_operation_latest_pointer()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $guard$
|
||||
begin
|
||||
if old.latest_published_version is not null
|
||||
and (new.latest_published_version is null
|
||||
or new.latest_published_version < old.latest_published_version) then
|
||||
raise exception 'latest published operation version cannot rewind'
|
||||
using errcode = '55000';
|
||||
end if;
|
||||
if new.latest_published_version is distinct from old.latest_published_version
|
||||
and new.latest_published_version is not null
|
||||
and not exists (
|
||||
select 1 from published_operations po
|
||||
where po.operation_id = new.id
|
||||
and po.version = new.latest_published_version
|
||||
) then
|
||||
raise exception 'latest published operation version has no authoritative pointer'
|
||||
using errcode = '55000';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$guard$;
|
||||
|
||||
create trigger operations_latest_pointer_monotonic_guard
|
||||
before update on operations
|
||||
for each row execute function crank_guard_operation_latest_pointer();
|
||||
@@ -0,0 +1,40 @@
|
||||
pub(super) const BASELINE: &[&str] = &[
|
||||
"workspaces",
|
||||
"users",
|
||||
"memberships",
|
||||
"user_sessions",
|
||||
"invitation_tokens",
|
||||
"platform_api_keys",
|
||||
"operations",
|
||||
"operation_versions",
|
||||
"published_operations",
|
||||
"operation_samples",
|
||||
"descriptors",
|
||||
"agents",
|
||||
"agent_versions",
|
||||
"published_agents",
|
||||
"agent_operation_bindings",
|
||||
"secrets",
|
||||
"secret_versions",
|
||||
"auth_profiles",
|
||||
"workspace_upstreams",
|
||||
"yaml_import_jobs",
|
||||
"import_jobs",
|
||||
"approval_requests",
|
||||
"invocation_logs",
|
||||
"usage_rollups",
|
||||
];
|
||||
|
||||
pub(super) const ONBOARDING_PRODUCT_EVENTS: &[&str] = &[
|
||||
"product_events",
|
||||
"product_event_daily_rollups",
|
||||
"onboarding_selections",
|
||||
];
|
||||
|
||||
pub(super) const CONSOLIDATION: &[&str] = &[
|
||||
"__crank_migrations",
|
||||
"__crank_migration_legacy_audit",
|
||||
"__crank_mcp_migrations",
|
||||
"mcp_transport_sessions",
|
||||
"__crank_ext_migrations",
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("platform_key_name_reuse_v6.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"94dba9b9dd3364607bc37e7698478ba6644607a7f268a1621cd3acea6bc96c2d";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.platform_key_name_reuse",
|
||||
Some(6),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(6),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
drop index if exists platform_api_keys_workspace_name_idx;
|
||||
|
||||
create unique index if not exists platform_api_keys_workspace_name_active_idx
|
||||
on platform_api_keys(workspace_id, name)
|
||||
where status <> 'deleted';
|
||||
@@ -26,6 +26,8 @@ pub(super) const OWNED_RELATIONS: &[&str] = &[
|
||||
"agent_operation_bindings",
|
||||
"secrets",
|
||||
"secret_versions",
|
||||
"master_key_identities",
|
||||
"master_key_rotations",
|
||||
"auth_profiles",
|
||||
"workspace_upstreams",
|
||||
"yaml_import_jobs",
|
||||
@@ -77,6 +79,37 @@ const REQUIRED_COLUMNS: &[(&str, &[&str])] = &[
|
||||
"expires_at",
|
||||
],
|
||||
),
|
||||
(
|
||||
"master_key_identities",
|
||||
&[
|
||||
"epoch",
|
||||
"fingerprint",
|
||||
"cipher_contract",
|
||||
"status",
|
||||
"backup_ref",
|
||||
"created_at",
|
||||
"activated_at",
|
||||
"retired_at",
|
||||
],
|
||||
),
|
||||
(
|
||||
"master_key_rotations",
|
||||
&[
|
||||
"id",
|
||||
"source_epoch",
|
||||
"target_epoch",
|
||||
"target_fingerprint",
|
||||
"state",
|
||||
"backup_ref",
|
||||
"checkpoint_secret_id",
|
||||
"total_secret_versions",
|
||||
"processed_secret_versions",
|
||||
"verified_secret_versions",
|
||||
"failure_code",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
const REQUIRED_COLUMN_TYPES: &[(&str, &str, &str, bool)] = &[
|
||||
@@ -165,6 +198,67 @@ const REQUIRED_COLUMN_TYPES: &[(&str, &str, &str, bool)] = &[
|
||||
"timestamp with time zone",
|
||||
true,
|
||||
),
|
||||
("master_key_identities", "epoch", "bigint", false),
|
||||
("master_key_identities", "fingerprint", "text", false),
|
||||
("master_key_identities", "cipher_contract", "text", false),
|
||||
("master_key_identities", "status", "text", false),
|
||||
("master_key_identities", "backup_ref", "text", true),
|
||||
(
|
||||
"master_key_identities",
|
||||
"created_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"master_key_identities",
|
||||
"activated_at",
|
||||
"timestamp with time zone",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"master_key_identities",
|
||||
"retired_at",
|
||||
"timestamp with time zone",
|
||||
true,
|
||||
),
|
||||
("master_key_rotations", "id", "text", false),
|
||||
("master_key_rotations", "source_epoch", "bigint", false),
|
||||
("master_key_rotations", "target_epoch", "bigint", false),
|
||||
("master_key_rotations", "target_fingerprint", "text", false),
|
||||
("master_key_rotations", "state", "text", false),
|
||||
("master_key_rotations", "backup_ref", "text", true),
|
||||
("master_key_rotations", "checkpoint_secret_id", "text", true),
|
||||
(
|
||||
"master_key_rotations",
|
||||
"total_secret_versions",
|
||||
"bigint",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"master_key_rotations",
|
||||
"processed_secret_versions",
|
||||
"bigint",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"master_key_rotations",
|
||||
"verified_secret_versions",
|
||||
"bigint",
|
||||
false,
|
||||
),
|
||||
("master_key_rotations", "failure_code", "text", true),
|
||||
(
|
||||
"master_key_rotations",
|
||||
"created_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"master_key_rotations",
|
||||
"updated_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
pub(super) async fn relation_exists(
|
||||
@@ -363,9 +457,397 @@ pub(super) async fn validate_schema_fingerprint(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let lifecycle_columns = [
|
||||
"name",
|
||||
"display_name",
|
||||
"category",
|
||||
"protocol",
|
||||
"security_level",
|
||||
"snapshot_provenance",
|
||||
"snapshot_observed_at",
|
||||
"published_at",
|
||||
"published_by",
|
||||
];
|
||||
for column in lifecycle_columns {
|
||||
let present = query(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'operation_versions'
|
||||
and column_name = $1
|
||||
) as present",
|
||||
)
|
||||
.bind(column)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if present != (current_version >= 4) {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
if current_version >= 4 {
|
||||
for (table, trigger) in [
|
||||
("operation_versions", "operation_versions_immutable_guard"),
|
||||
(
|
||||
"published_operations",
|
||||
"published_operations_monotonic_guard",
|
||||
),
|
||||
("operations", "operations_latest_pointer_monotonic_guard"),
|
||||
] {
|
||||
let trigger_present = query(
|
||||
"select exists (
|
||||
select 1 from pg_catalog.pg_trigger tg
|
||||
join pg_catalog.pg_class t on t.oid = tg.tgrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema()
|
||||
and t.relname = $1
|
||||
and tg.tgname = $2
|
||||
and not tg.tgisinternal
|
||||
) as present",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(trigger)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if !trigger_present {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
}
|
||||
let outcome_columns = [
|
||||
"operation_version",
|
||||
"execution_stage",
|
||||
"execution_error_code",
|
||||
"retryability",
|
||||
"outcome_certainty",
|
||||
];
|
||||
for column in outcome_columns {
|
||||
let row = query(
|
||||
"select data_type, is_nullable from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'invocation_logs'
|
||||
and column_name = $1",
|
||||
)
|
||||
.bind(column)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if current_version < 5 {
|
||||
if row.is_some() {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
let expected_type = if column == "operation_version" {
|
||||
"integer"
|
||||
} else {
|
||||
"text"
|
||||
};
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("data_type").ok().as_deref() == Some(expected_type)
|
||||
&& row.try_get::<String, _>("is_nullable").ok().as_deref() == Some("YES")
|
||||
});
|
||||
if !valid {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
}
|
||||
if current_version >= 5 {
|
||||
validate_v5_execution_contract(connection).await?;
|
||||
}
|
||||
if current_version >= 6 {
|
||||
validate_v6_platform_key_name_reuse(connection).await?;
|
||||
}
|
||||
if current_version < 7 {
|
||||
let v7_relations_present = relation_exists(connection, "master_key_identities").await?
|
||||
|| relation_exists(connection, "master_key_rotations").await?;
|
||||
let mut v7_secret_columns_present = false;
|
||||
for column in [
|
||||
"master_key_epoch",
|
||||
"target_ciphertext",
|
||||
"target_key_version",
|
||||
"target_master_key_epoch",
|
||||
] {
|
||||
v7_secret_columns_present |=
|
||||
column_exists(connection, "secret_versions", column).await?;
|
||||
}
|
||||
if v7_relations_present || v7_secret_columns_present {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
super::schema_guard_v7::validate_v7_master_key_identity(connection).await?;
|
||||
}
|
||||
if current_version < 8 {
|
||||
let v8_relations_present = relation_exists(connection, "admin_bootstrap_contracts").await?
|
||||
|| relation_exists(connection, "admin_login_backoff").await?
|
||||
|| relation_exists(connection, "admin_security_audit_events").await?;
|
||||
let v8_session_columns_present = column_exists(connection, "user_sessions", "csrf_hash")
|
||||
.await?
|
||||
|| column_exists(connection, "user_sessions", "revoked_at").await?;
|
||||
if v8_relations_present || v8_session_columns_present {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
super::schema_guard_v8::validate_v8_admin_auth_lifecycle(connection).await?;
|
||||
}
|
||||
if current_version < 9 {
|
||||
if !super::schema_guard_v9::validate_v9_absent(connection).await? {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
super::schema_guard_v9::validate_v9_agent_catalog_lifecycle(connection).await?;
|
||||
}
|
||||
if current_version < 10 {
|
||||
if !super::schema_guard_v10::validate_v10_absent(connection).await? {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
super::schema_guard_v10::validate_v10_approval_side_effects(connection).await?;
|
||||
}
|
||||
if current_version < 11 {
|
||||
if !super::schema_guard_v11::validate_v11_absent(connection).await? {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
super::schema_guard_v11::validate_v11_onboarding_product_events(connection).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn column_exists(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
column: &str,
|
||||
) -> Result<bool, MigrationError> {
|
||||
query(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = $1
|
||||
and column_name = $2
|
||||
) as present",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(column)
|
||||
.fetch_one(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))
|
||||
}
|
||||
|
||||
async fn validate_v5_execution_contract(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
let operation_version = constraint_expression(
|
||||
connection,
|
||||
"invocation_logs",
|
||||
"invocation_logs_operation_version_check",
|
||||
)
|
||||
.await?;
|
||||
if normalize_definition(&operation_version) != "operation_versionisnulloroperation_version>0" {
|
||||
return Err(schema_error(5));
|
||||
}
|
||||
for (constraint, column, allowed) in [
|
||||
(
|
||||
"invocation_logs_execution_stage_check",
|
||||
"execution_stage",
|
||||
&[
|
||||
"authorization",
|
||||
"input_schema",
|
||||
"input_mapping",
|
||||
"request_preparation",
|
||||
"admission",
|
||||
"adapter",
|
||||
"upstream",
|
||||
"output_mapping",
|
||||
"output_schema",
|
||||
"mandatory_persistence",
|
||||
"runtime",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"invocation_logs_execution_error_code_check",
|
||||
"execution_error_code",
|
||||
&[
|
||||
"authorization_denied",
|
||||
"auth_profile_not_found",
|
||||
"secret_not_found",
|
||||
"secret_invalid",
|
||||
"input_schema_invalid",
|
||||
"input_mapping_invalid",
|
||||
"prepared_request_invalid",
|
||||
"execution_overloaded",
|
||||
"safety_store_unavailable",
|
||||
"protocol_unsupported",
|
||||
"execution_mode_unsupported",
|
||||
"adapter_configuration_invalid",
|
||||
"outbound_target_rejected",
|
||||
"upstream_auth_error",
|
||||
"upstream_not_found",
|
||||
"upstream_rate_limited",
|
||||
"upstream_server_error",
|
||||
"upstream_status_error",
|
||||
"upstream_timeout",
|
||||
"upstream_transport_error",
|
||||
"upstream_request_too_large",
|
||||
"upstream_response_too_large",
|
||||
"output_mapping_invalid",
|
||||
"output_schema_invalid",
|
||||
"persistence_unavailable",
|
||||
"runtime_internal",
|
||||
"confirmation_required",
|
||||
"confirmation_invalid",
|
||||
"idempotency_in_progress",
|
||||
"idempotency_conflict",
|
||||
"idempotency_outcome_unknown",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"invocation_logs_retryability_check",
|
||||
"retryability",
|
||||
&[
|
||||
"never",
|
||||
"safe",
|
||||
"after_delay",
|
||||
"manual_reconcile",
|
||||
"requires_confirmation",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"invocation_logs_outcome_certainty_check",
|
||||
"outcome_certainty",
|
||||
&["certain", "outcome_unknown"][..],
|
||||
),
|
||||
] {
|
||||
let expression = constraint_expression(connection, "invocation_logs", constraint).await?;
|
||||
if !enum_constraint_matches(&expression, column, allowed) {
|
||||
return Err(schema_error(5));
|
||||
}
|
||||
}
|
||||
validate_v5_index(connection).await
|
||||
}
|
||||
|
||||
pub(super) async fn constraint_expression(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
constraint: &str,
|
||||
) -> Result<String, MigrationError> {
|
||||
query(
|
||||
"select pg_get_expr(c.conbin, c.conrelid) as expression
|
||||
from pg_catalog.pg_constraint c
|
||||
join pg_catalog.pg_class t on t.oid = c.conrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema() and t.relname = $1 and c.conname = $2 and c.contype = 'c'",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(constraint)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.and_then(|row| row.try_get::<String, _>("expression").ok())
|
||||
.ok_or_else(|| schema_error(5))
|
||||
}
|
||||
|
||||
pub(super) fn enum_constraint_matches(expression: &str, column: &str, allowed: &[&str]) -> bool {
|
||||
let normalized = normalize_definition(expression);
|
||||
if !normalized.contains(column) || normalized.contains("ortrue") {
|
||||
return false;
|
||||
}
|
||||
let mut values = expression
|
||||
.split('\'')
|
||||
.enumerate()
|
||||
.filter_map(|(index, value)| (index % 2 == 1).then_some(value))
|
||||
.collect::<Vec<_>>();
|
||||
values.sort_unstable();
|
||||
values.dedup();
|
||||
let mut expected = allowed.to_vec();
|
||||
expected.sort_unstable();
|
||||
values == expected
|
||||
}
|
||||
|
||||
async fn validate_v5_index(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let row = query(
|
||||
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
|
||||
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
|
||||
pg_get_indexdef(i.indexrelid, 3, true) as third_column,
|
||||
pg_get_expr(i.indpred, i.indrelid) as predicate
|
||||
from pg_catalog.pg_index i
|
||||
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
join pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema()
|
||||
and idx.relname = 'invocation_logs_workspace_operation_version_idx'",
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some("invocation_logs")
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(false)
|
||||
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
|
||||
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some("operation_id")
|
||||
&& row.try_get::<String, _>("third_column").ok().as_deref() == Some("operation_version")
|
||||
&& row
|
||||
.try_get::<String, _>("predicate")
|
||||
.ok()
|
||||
.is_some_and(|value| normalize_definition(&value) == "operation_versionisnotnull")
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(5)) }
|
||||
}
|
||||
|
||||
async fn validate_v6_platform_key_name_reuse(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
if relation_exists(connection, "platform_api_keys_workspace_name_idx").await? {
|
||||
return Err(schema_error(6));
|
||||
}
|
||||
let row = query(
|
||||
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
|
||||
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
|
||||
pg_get_expr(i.indpred, i.indrelid) as predicate
|
||||
from pg_catalog.pg_index i
|
||||
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
join pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema()
|
||||
and idx.relname = 'platform_api_keys_workspace_name_active_idx'",
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some("platform_api_keys")
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(true)
|
||||
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
|
||||
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some("name")
|
||||
&& row
|
||||
.try_get::<String, _>("predicate")
|
||||
.ok()
|
||||
.is_some_and(|value| {
|
||||
matches!(
|
||||
normalize_definition(&value).as_str(),
|
||||
"status<>'deleted'::text" | "status!='deleted'::text"
|
||||
)
|
||||
})
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(6)) }
|
||||
}
|
||||
|
||||
async fn named_constraint_exists(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
@@ -433,7 +915,7 @@ async fn validate_index(
|
||||
if valid { Ok(()) } else { Err(schema_error(3)) }
|
||||
}
|
||||
|
||||
fn normalize_definition(value: &str) -> String {
|
||||
pub(super) fn normalize_definition(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| !character.is_ascii_whitespace() && !matches!(character, '(' | ')'))
|
||||
@@ -441,7 +923,7 @@ fn normalize_definition(value: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn schema_error(version: i64) -> MigrationError {
|
||||
pub(super) fn schema_error(version: i64) -> MigrationError {
|
||||
MigrationError::new(
|
||||
"partial_sequence",
|
||||
"preflight.schema",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use sqlx::{PgConnection, Row, query};
|
||||
|
||||
use super::authority::MigrationError;
|
||||
use super::schema_guard::{normalize_definition, relation_exists, schema_error};
|
||||
|
||||
pub(super) async fn validate_v10_absent(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<bool, MigrationError> {
|
||||
let old_present =
|
||||
relation_exists(connection, "approval_requests_pending_fingerprint_idx").await?;
|
||||
let new_present = relation_exists(
|
||||
connection,
|
||||
"approval_requests_pending_scope_fingerprint_idx",
|
||||
)
|
||||
.await?;
|
||||
Ok(old_present && !new_present)
|
||||
}
|
||||
|
||||
pub(super) async fn validate_v10_approval_side_effects(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
if !relation_exists(connection, "approval_requests_pending_fingerprint_idx").await? {
|
||||
return Err(schema_error(10));
|
||||
}
|
||||
for column in ["request_id", "trace_id"] {
|
||||
let present = query(
|
||||
"select 1
|
||||
from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'approval_requests'
|
||||
and column_name = $1",
|
||||
)
|
||||
.bind(column)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.is_some();
|
||||
if !present {
|
||||
return Err(schema_error(10));
|
||||
}
|
||||
}
|
||||
let row = query(
|
||||
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
|
||||
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
|
||||
pg_get_indexdef(i.indexrelid, 3, true) as third_column,
|
||||
pg_get_indexdef(i.indexrelid, 4, true) as fourth_column,
|
||||
pg_get_indexdef(i.indexrelid, 5, true) as fifth_column,
|
||||
pg_get_expr(i.indpred, i.indrelid) as predicate
|
||||
from pg_catalog.pg_index i
|
||||
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
join pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema()
|
||||
and idx.relname = 'approval_requests_pending_scope_fingerprint_idx'",
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some("approval_requests")
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(true)
|
||||
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
|
||||
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some("agent_id")
|
||||
&& row.try_get::<String, _>("third_column").ok().as_deref() == Some("operation_id")
|
||||
&& row.try_get::<String, _>("fourth_column").ok().as_deref()
|
||||
== Some("operation_version")
|
||||
&& row.try_get::<String, _>("fifth_column").ok().as_deref()
|
||||
== Some("request_fingerprint")
|
||||
&& row
|
||||
.try_get::<String, _>("predicate")
|
||||
.ok()
|
||||
.is_some_and(|value| {
|
||||
normalize_definition(&value)
|
||||
== "status='pending'::textandrequest_fingerprintisnotnull"
|
||||
})
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(10)) }
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use sqlx::{PgConnection, Row, query};
|
||||
|
||||
use super::authority::MigrationError;
|
||||
use super::schema_guard::{
|
||||
column_exists, constraint_expression, normalize_definition, relation_exists, schema_error,
|
||||
};
|
||||
|
||||
pub(super) async fn validate_v11_absent(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<bool, MigrationError> {
|
||||
Ok(!relation_exists(connection, "product_events").await?
|
||||
&& !relation_exists(connection, "product_event_daily_rollups").await?
|
||||
&& !relation_exists(connection, "onboarding_selections").await?
|
||||
&& !column_exists(connection, "invocation_logs", "platform_api_key_id").await?)
|
||||
}
|
||||
|
||||
pub(super) async fn validate_v11_onboarding_product_events(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
for relation in [
|
||||
"product_events",
|
||||
"product_event_daily_rollups",
|
||||
"onboarding_selections",
|
||||
] {
|
||||
if !relation_exists(connection, relation).await? {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
if !column_exists(connection, "invocation_logs", "platform_api_key_id").await? {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
for relation in [
|
||||
"product_events_workspace_id_idempotency_key_key",
|
||||
"product_events_workspace_occurred_idx",
|
||||
"invocation_logs_workspace_agent_key_success_idx",
|
||||
] {
|
||||
if !relation_exists(connection, relation).await? {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
for (table, constraint, required) in [
|
||||
(
|
||||
"product_events",
|
||||
"product_events_id_check",
|
||||
&["id~", "^pe_[a-za-z0-9_-]{1,128}$"][..],
|
||||
),
|
||||
(
|
||||
"product_events",
|
||||
"product_events_name_check",
|
||||
&[
|
||||
"event_name=any",
|
||||
"onboarding_eligible",
|
||||
"onboarding_started",
|
||||
"onboarding_resumed",
|
||||
"onboarding_dismissed",
|
||||
"onboarding_abandoned",
|
||||
"onboarding_completed",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"product_events",
|
||||
"product_events_schema_version_check",
|
||||
&["schema_version=1"][..],
|
||||
),
|
||||
(
|
||||
"product_events",
|
||||
"product_events_idempotency_key_check",
|
||||
&[
|
||||
"octet_lengthidempotency_key>=1",
|
||||
"octet_lengthidempotency_key<=256",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"product_events",
|
||||
"product_events_properties_check",
|
||||
&[
|
||||
"jsonb_typeofproperties_json='object'",
|
||||
"pg_column_sizeproperties_json<=4096",
|
||||
"event_name<>'onboarding_eligible'",
|
||||
"properties_json@>'{\"eligible\":true}'",
|
||||
"jsonb_typeofproperties_json->'eligible_since'",
|
||||
"='string'",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"product_event_daily_rollups",
|
||||
"product_event_daily_rollups_name_check",
|
||||
&[
|
||||
"event_name=any",
|
||||
"onboarding_eligible",
|
||||
"onboarding_completed",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"product_event_daily_rollups",
|
||||
"product_event_daily_rollups_counts_check",
|
||||
&[
|
||||
"events_total>=0",
|
||||
"eligible_total>=0",
|
||||
"eligible_total<=events_total",
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
let definition =
|
||||
normalize_definition(&constraint_expression(connection, table, constraint).await?);
|
||||
if required.iter().any(|snippet| !definition.contains(snippet)) {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
validate_index(
|
||||
connection,
|
||||
"product_events_workspace_occurred_idx",
|
||||
"product_events",
|
||||
false,
|
||||
&["workspace_id", "occurred_at", "id"],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
validate_index(
|
||||
connection,
|
||||
"invocation_logs_workspace_agent_key_success_idx",
|
||||
"invocation_logs",
|
||||
false,
|
||||
&[
|
||||
"workspace_id",
|
||||
"agent_id",
|
||||
"platform_api_key_id",
|
||||
"created_atdesc",
|
||||
],
|
||||
&[
|
||||
"platform_api_key_idisnotnull",
|
||||
"source='agent_tool_call'",
|
||||
"status='ok'",
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let key_scope_fk = constraint_definition(
|
||||
connection,
|
||||
"invocation_logs",
|
||||
"invocation_logs_platform_key_scope_fk",
|
||||
)
|
||||
.await?;
|
||||
for required in [
|
||||
"foreignkeyworkspace_id,agent_id,platform_api_key_id",
|
||||
"referencesplatform_api_keysworkspace_id,agent_id,id",
|
||||
"ondeletesetnullplatform_api_key_id",
|
||||
] {
|
||||
if !key_scope_fk.contains(required) {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
for (kind, name) in [
|
||||
("constraint", "invocation_logs_platform_key_scope_fk"),
|
||||
("trigger", "product_events_append_only_guard"),
|
||||
] {
|
||||
let present: bool = match kind {
|
||||
"constraint" => query(
|
||||
"select exists (select 1 from pg_constraint c
|
||||
join pg_namespace n on n.oid = c.connamespace
|
||||
where n.nspname = current_schema() and c.conname = $1) as present",
|
||||
),
|
||||
_ => query(
|
||||
"select exists (select 1 from pg_trigger t
|
||||
join pg_class c on c.oid = t.tgrelid
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = current_schema() and t.tgname = $1 and not t.tgisinternal) as present",
|
||||
),
|
||||
}
|
||||
.bind(name)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if !present {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
let nullable = query(
|
||||
"select is_nullable from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'invocation_logs'
|
||||
and column_name = 'platform_api_key_id'",
|
||||
)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.and_then(|row| row.try_get::<String, _>("is_nullable").ok());
|
||||
if nullable.as_deref() != Some("YES") {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
let shape = constraint_expression(
|
||||
connection,
|
||||
"onboarding_selections",
|
||||
"onboarding_selections_shape_check",
|
||||
)
|
||||
.await?;
|
||||
let normalized_shape = normalize_definition(&shape);
|
||||
for required in [
|
||||
"operation_idisnull",
|
||||
"operation_version>0",
|
||||
"catalog_revision>0",
|
||||
"invocation_log_idisnotnull",
|
||||
"selected_atisnull",
|
||||
"selected_atisnotnull",
|
||||
] {
|
||||
if !normalized_shape.contains(required) {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
let function_definition = query(
|
||||
"select p.prosrc as definition
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = current_schema()
|
||||
and p.proname = 'crank_reject_product_event_mutation'",
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.and_then(|row| row.try_get::<String, _>("definition").ok());
|
||||
if !function_definition.is_some_and(|definition| {
|
||||
let normalized = normalize_definition(&definition);
|
||||
[
|
||||
"notexists",
|
||||
"fromworkspaces",
|
||||
"old.workspace_id",
|
||||
"returnold",
|
||||
"producteventisappend-only",
|
||||
]
|
||||
.into_iter()
|
||||
.all(|required| normalized.contains(required))
|
||||
}) {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn constraint_definition(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
constraint: &str,
|
||||
) -> Result<String, MigrationError> {
|
||||
query(
|
||||
"select pg_get_constraintdef(c.oid, true) as definition
|
||||
from pg_catalog.pg_constraint c
|
||||
join pg_catalog.pg_class t on t.oid = c.conrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema() and t.relname = $1 and c.conname = $2",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(constraint)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.ok_or_else(|| schema_error(11))?
|
||||
.try_get::<String, _>("definition")
|
||||
.map(|definition| normalize_definition(&definition))
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))
|
||||
}
|
||||
|
||||
async fn validate_index(
|
||||
connection: &mut PgConnection,
|
||||
index: &str,
|
||||
expected_table: &str,
|
||||
expected_unique: bool,
|
||||
expected_columns: &[&str],
|
||||
predicate_snippets: &[&str],
|
||||
) -> Result<(), MigrationError> {
|
||||
let row = query(
|
||||
"select t.relname as table_name, am.amname as access_method,
|
||||
i.indisvalid, i.indisready, i.indisunique,
|
||||
pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
|
||||
pg_get_indexdef(i.indexrelid, 3, true) as third_column,
|
||||
pg_get_indexdef(i.indexrelid, 4, true) as fourth_column,
|
||||
pg_get_indexdef(i.indexrelid) as definition,
|
||||
coalesce(pg_get_expr(i.indpred, i.indrelid), '') as predicate
|
||||
from pg_catalog.pg_index i
|
||||
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
join pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema() and idx.relname = $1",
|
||||
)
|
||||
.bind(index)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
let actual_columns = [
|
||||
"first_column",
|
||||
"second_column",
|
||||
"third_column",
|
||||
"fourth_column",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|field| row.try_get::<Option<String>, _>(field).ok().flatten())
|
||||
.map(|value| normalize_definition(&value))
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let expected_base_columns = expected_columns
|
||||
.iter()
|
||||
.map(|column| column.strip_suffix("desc").unwrap_or(column).to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let definition = row
|
||||
.try_get::<String, _>("definition")
|
||||
.ok()
|
||||
.map(|value| normalize_definition(&value))
|
||||
.unwrap_or_default();
|
||||
let predicate = row
|
||||
.try_get::<String, _>("predicate")
|
||||
.ok()
|
||||
.map(|value| normalize_definition(&value))
|
||||
.unwrap_or_default();
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some(expected_table)
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(expected_unique)
|
||||
&& actual_columns == expected_base_columns
|
||||
&& expected_columns
|
||||
.iter()
|
||||
.filter(|column| column.ends_with("desc"))
|
||||
.all(|column| definition.contains(column))
|
||||
&& predicate_snippets
|
||||
.iter()
|
||||
.all(|snippet| predicate.contains(snippet))
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(11)) }
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use sqlx::{PgConnection, Row, query};
|
||||
|
||||
use super::{
|
||||
authority::MigrationError,
|
||||
schema_guard::{
|
||||
column_exists, constraint_expression, enum_constraint_matches, normalize_definition,
|
||||
relation_exists, schema_error,
|
||||
},
|
||||
};
|
||||
|
||||
pub(super) async fn validate_v7_master_key_identity(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
for relation in ["master_key_identities", "master_key_rotations"] {
|
||||
if !relation_exists(connection, relation).await? {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
}
|
||||
for (column, data_type, nullable) in [
|
||||
("master_key_epoch", "bigint", false),
|
||||
("target_ciphertext", "text", true),
|
||||
("target_key_version", "text", true),
|
||||
("target_master_key_epoch", "bigint", true),
|
||||
] {
|
||||
if !secret_version_column_matches(connection, column, data_type, nullable).await? {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
}
|
||||
validate_identity_constraints(connection).await?;
|
||||
validate_rotation_constraints(connection).await?;
|
||||
validate_secret_version_constraints(connection).await?;
|
||||
validate_active_identity_index(connection).await
|
||||
}
|
||||
|
||||
async fn secret_version_column_matches(
|
||||
connection: &mut PgConnection,
|
||||
column: &str,
|
||||
data_type: &str,
|
||||
nullable: bool,
|
||||
) -> Result<bool, MigrationError> {
|
||||
if !column_exists(connection, "secret_versions", column).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
let row = query(
|
||||
"select data_type, is_nullable from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'secret_versions'
|
||||
and column_name = $1",
|
||||
)
|
||||
.bind(column)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
Ok(row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("data_type").ok().as_deref() == Some(data_type)
|
||||
&& row.try_get::<String, _>("is_nullable").ok().as_deref()
|
||||
== Some(if nullable { "YES" } else { "NO" })
|
||||
}))
|
||||
}
|
||||
|
||||
async fn validate_identity_constraints(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
let identity_epoch = constraint_expression(
|
||||
connection,
|
||||
"master_key_identities",
|
||||
"master_key_identities_epoch_check",
|
||||
)
|
||||
.await?;
|
||||
if normalize_definition(&identity_epoch) != "epoch>0" {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
let identity_fingerprint = constraint_expression(
|
||||
connection,
|
||||
"master_key_identities",
|
||||
"master_key_identities_fingerprint_check",
|
||||
)
|
||||
.await?;
|
||||
if !regex_constraint_matches(&identity_fingerprint, "fingerprint", "^[0-9a-f]{64}$") {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
let identity_cipher = constraint_expression(
|
||||
connection,
|
||||
"master_key_identities",
|
||||
"master_key_identities_cipher_contract_check",
|
||||
)
|
||||
.await?;
|
||||
if !enum_constraint_matches(
|
||||
&identity_cipher,
|
||||
"cipher_contract",
|
||||
&["secret-envelope-v2/aes-256-gcm-hkdf-sha256"],
|
||||
) {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
let identity_status = constraint_expression(
|
||||
connection,
|
||||
"master_key_identities",
|
||||
"master_key_identities_status_check",
|
||||
)
|
||||
.await?;
|
||||
if enum_constraint_matches(
|
||||
&identity_status,
|
||||
"status",
|
||||
&["active", "pending", "retired", "revoked"],
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(schema_error(7))
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_rotation_constraints(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
let rotation_state = constraint_expression(
|
||||
connection,
|
||||
"master_key_rotations",
|
||||
"master_key_rotations_state_check",
|
||||
)
|
||||
.await?;
|
||||
if enum_constraint_matches(
|
||||
&rotation_state,
|
||||
"state",
|
||||
&[
|
||||
"preflighted",
|
||||
"running",
|
||||
"verifying",
|
||||
"verified",
|
||||
"promoted",
|
||||
"aborted",
|
||||
"failed",
|
||||
],
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(schema_error(7))
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_secret_version_constraints(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
let secret_epoch = constraint_expression(
|
||||
connection,
|
||||
"secret_versions",
|
||||
"secret_versions_master_key_epoch_check",
|
||||
)
|
||||
.await?;
|
||||
if normalize_definition(&secret_epoch) != "master_key_epoch>0" {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
let secret_target_epoch = constraint_expression(
|
||||
connection,
|
||||
"secret_versions",
|
||||
"secret_versions_target_epoch_check",
|
||||
)
|
||||
.await?;
|
||||
if normalize_definition(&secret_target_epoch)
|
||||
!= "target_master_key_epochisnullortarget_master_key_epoch>master_key_epoch"
|
||||
{
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
let secret_all_or_none = constraint_expression(
|
||||
connection,
|
||||
"secret_versions",
|
||||
"secret_versions_target_all_or_none_check",
|
||||
)
|
||||
.await?;
|
||||
if target_ciphertext_all_or_none_matches(&secret_all_or_none) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(schema_error(7))
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_active_identity_index(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
let active_index = query(
|
||||
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
|
||||
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_expr(i.indpred, i.indrelid) as predicate
|
||||
from pg_catalog.pg_index i
|
||||
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
join pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema()
|
||||
and idx.relname = 'master_key_identities_active_idx'",
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = active_index.is_some_and(|row| {
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some("master_key_identities")
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(true)
|
||||
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("status")
|
||||
&& row
|
||||
.try_get::<String, _>("predicate")
|
||||
.ok()
|
||||
.is_some_and(|value| normalize_definition(&value) == "status='active'::text")
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(7)) }
|
||||
}
|
||||
|
||||
fn regex_constraint_matches(expression: &str, column: &str, pattern: &str) -> bool {
|
||||
let normalized = normalize_definition(expression);
|
||||
if !normalized.contains(column) || normalized.contains("ortrue") {
|
||||
return false;
|
||||
}
|
||||
let values = expression
|
||||
.split('\'')
|
||||
.enumerate()
|
||||
.filter_map(|(index, value)| (index % 2 == 1).then_some(value))
|
||||
.collect::<Vec<_>>();
|
||||
values == [pattern]
|
||||
}
|
||||
|
||||
fn target_ciphertext_all_or_none_matches(expression: &str) -> bool {
|
||||
let normalized = normalize_definition(expression);
|
||||
!normalized.contains("ortrue")
|
||||
&& normalized.contains("target_ciphertextisnull")
|
||||
&& normalized.contains("target_key_versionisnull")
|
||||
&& normalized.contains("target_master_key_epochisnull")
|
||||
&& normalized.contains("target_ciphertextisnotnull")
|
||||
&& normalized.contains("target_key_versionisnotnull")
|
||||
&& normalized.contains("target_master_key_epochisnotnull")
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use sqlx::PgConnection;
|
||||
|
||||
use super::authority::MigrationError;
|
||||
use super::schema_guard::{column_exists, relation_exists, schema_error};
|
||||
|
||||
pub(super) async fn validate_v8_admin_auth_lifecycle(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
for relation in [
|
||||
"admin_bootstrap_contracts",
|
||||
"admin_bootstrap_contracts_single_active_idx",
|
||||
"admin_bootstrap_contracts_token_hash_idx",
|
||||
"admin_login_backoff",
|
||||
"admin_security_audit_events",
|
||||
"admin_security_audit_events_created_idx",
|
||||
] {
|
||||
if !relation_exists(connection, relation).await? {
|
||||
return Err(schema_error(8));
|
||||
}
|
||||
}
|
||||
for column in ["csrf_hash", "revoked_at"] {
|
||||
if !column_exists(connection, "user_sessions", column).await? {
|
||||
return Err(schema_error(8));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use sqlx::{PgConnection, Row, query};
|
||||
|
||||
use super::authority::MigrationError;
|
||||
use super::schema_guard::{
|
||||
column_exists, constraint_expression, normalize_definition, schema_error,
|
||||
};
|
||||
|
||||
pub(super) async fn validate_v9_absent(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<bool, MigrationError> {
|
||||
let columns_present = column_exists(connection, "agents", "catalog_revision").await?
|
||||
|| column_exists(connection, "published_agents", "catalog_revision").await?;
|
||||
let triggers_present = trigger_exists(
|
||||
connection,
|
||||
"agent_versions",
|
||||
"agent_versions_immutable_guard",
|
||||
)
|
||||
.await?
|
||||
|| trigger_exists(
|
||||
connection,
|
||||
"agent_operation_bindings",
|
||||
"agent_operation_bindings_immutable_guard",
|
||||
)
|
||||
.await?
|
||||
|| trigger_exists(
|
||||
connection,
|
||||
"published_agents",
|
||||
"published_agents_monotonic_guard",
|
||||
)
|
||||
.await?;
|
||||
Ok(!columns_present && !triggers_present)
|
||||
}
|
||||
|
||||
pub(super) async fn validate_v9_agent_catalog_lifecycle(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
for (table, column) in [
|
||||
("agents", "catalog_revision"),
|
||||
("published_agents", "catalog_revision"),
|
||||
] {
|
||||
let row = query(
|
||||
"select data_type, is_nullable
|
||||
from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = $1
|
||||
and column_name = $2",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(column)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("data_type").ok().as_deref() == Some("bigint")
|
||||
&& row.try_get::<String, _>("is_nullable").ok().as_deref() == Some("NO")
|
||||
});
|
||||
if !valid {
|
||||
return Err(schema_error(9));
|
||||
}
|
||||
}
|
||||
|
||||
let agents_revision =
|
||||
constraint_expression(connection, "agents", "agents_catalog_revision_check").await?;
|
||||
if normalize_definition(&agents_revision) != "catalog_revision>=0" {
|
||||
return Err(schema_error(9));
|
||||
}
|
||||
let published_revision = constraint_expression(
|
||||
connection,
|
||||
"published_agents",
|
||||
"published_agents_catalog_revision_check",
|
||||
)
|
||||
.await?;
|
||||
if normalize_definition(&published_revision) != "catalog_revision>0" {
|
||||
return Err(schema_error(9));
|
||||
}
|
||||
|
||||
for (table, trigger, expected_events, expected_function, function_snippets) in [
|
||||
(
|
||||
"agent_versions",
|
||||
"agent_versions_immutable_guard",
|
||||
&["before", "update", "delete"][..],
|
||||
"crank_reject_published_agent_version_mutation",
|
||||
&["old.status='published'", "returnold", "returnnew"][..],
|
||||
),
|
||||
(
|
||||
"agent_operation_bindings",
|
||||
"agent_operation_bindings_immutable_guard",
|
||||
&["before", "insert", "update", "delete"][..],
|
||||
"crank_reject_published_agent_binding_mutation",
|
||||
&[
|
||||
"coalescenew.agent_id,old.agent_id",
|
||||
"bound_status='published'",
|
||||
"returnold",
|
||||
"returnnew",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"published_agents",
|
||||
"published_agents_monotonic_guard",
|
||||
&["before", "insert", "update", "delete"][..],
|
||||
"crank_reject_published_agent_pointer_rewind",
|
||||
&[
|
||||
"tg_op='delete'",
|
||||
"new.catalog_revision<=old.catalog_revision",
|
||||
"new.version<old.version",
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
let Some(trigger_definition) = trigger_definition(connection, table, trigger).await? else {
|
||||
return Err(schema_error(9));
|
||||
};
|
||||
let normalized_trigger = normalize_definition(&trigger_definition);
|
||||
if expected_events
|
||||
.iter()
|
||||
.any(|event| !normalized_trigger.contains(event))
|
||||
|| !normalized_trigger.contains(expected_function)
|
||||
{
|
||||
return Err(schema_error(9));
|
||||
}
|
||||
let function_definition = function_definition(connection, expected_function).await?;
|
||||
let normalized_function = normalize_definition(&function_definition);
|
||||
if function_snippets
|
||||
.iter()
|
||||
.any(|snippet| !normalized_function.contains(snippet))
|
||||
{
|
||||
return Err(schema_error(9));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn trigger_exists(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
trigger: &str,
|
||||
) -> Result<bool, MigrationError> {
|
||||
query(
|
||||
"select exists (
|
||||
select 1
|
||||
from pg_catalog.pg_trigger trg
|
||||
join pg_catalog.pg_class t on t.oid = trg.tgrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema()
|
||||
and t.relname = $1
|
||||
and trg.tgname = $2
|
||||
and not trg.tgisinternal
|
||||
) as present",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(trigger)
|
||||
.fetch_one(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))
|
||||
}
|
||||
|
||||
async fn trigger_definition(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
trigger: &str,
|
||||
) -> Result<Option<String>, MigrationError> {
|
||||
let row = query(
|
||||
"select pg_get_triggerdef(trg.oid) as definition
|
||||
from pg_catalog.pg_trigger trg
|
||||
join pg_catalog.pg_class t on t.oid = trg.tgrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema()
|
||||
and t.relname = $1
|
||||
and trg.tgname = $2
|
||||
and not trg.tgisinternal",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(trigger)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
row.map(|row| row.try_get::<String, _>("definition"))
|
||||
.transpose()
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))
|
||||
}
|
||||
|
||||
async fn function_definition(
|
||||
connection: &mut PgConnection,
|
||||
function_name: &str,
|
||||
) -> Result<String, MigrationError> {
|
||||
query(
|
||||
"select pg_get_functiondef(p.oid) as definition
|
||||
from pg_catalog.pg_proc p
|
||||
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = current_schema()
|
||||
and p.proname = $1",
|
||||
)
|
||||
.bind(function_name)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.ok_or_else(|| schema_error(9))?
|
||||
.try_get::<String, _>("definition")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
use crank_core::{
|
||||
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, ToolSelectionPolicy, UsagePeriod,
|
||||
UsageRollup, User, UserSessionId, Workspace, WorkspaceId,
|
||||
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExecutionErrorCode,
|
||||
ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource,
|
||||
InvocationStatus, MembershipRole, OnboardingProjection, Operation, OperationId,
|
||||
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, ProductEvent,
|
||||
ProductEventId, ProductEventKind, Protocol, SampleId, Secret, SecretId, SecretVersion,
|
||||
ToolSelectionPolicy, UsagePeriod, UsageRollup, User, UserId, UserSessionId, Workspace,
|
||||
WorkspaceId,
|
||||
};
|
||||
use crank_mapping::MappingSet;
|
||||
use crank_schema::Schema;
|
||||
@@ -84,6 +86,56 @@ pub struct SessionRecord {
|
||||
pub user: User,
|
||||
pub memberships: Vec<WorkspaceMembershipRecord>,
|
||||
pub current_workspace_id: Option<WorkspaceId>,
|
||||
pub csrf_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdminBootstrapContractRecord {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub status: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub expires_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub used_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CreateAdminBootstrapContractRequest<'a> {
|
||||
pub id: &'a str,
|
||||
pub token_hash: &'a str,
|
||||
pub email: &'a str,
|
||||
pub display_name: &'a str,
|
||||
pub expires_at: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConsumeAdminBootstrapContractRequest<'a> {
|
||||
pub token_hash: &'a str,
|
||||
pub password_hash: &'a str,
|
||||
pub now: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AdminSecurityAuditRequest<'a> {
|
||||
pub id: &'a str,
|
||||
pub action: &'a str,
|
||||
pub outcome: &'a str,
|
||||
pub actor_user_id: Option<&'a UserId>,
|
||||
pub session_id: Option<&'a UserSessionId>,
|
||||
pub request_id: Option<&'a str>,
|
||||
pub trace_id: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RecoverAdminPasswordRequest<'a> {
|
||||
pub email: &'a str,
|
||||
pub password_hash: &'a str,
|
||||
pub audit_id: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -109,6 +161,60 @@ pub struct SecretRecord {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SecretVersionRecord {
|
||||
pub secret_version: SecretVersion,
|
||||
pub master_key_epoch: i64,
|
||||
pub target_ciphertext: Option<String>,
|
||||
pub target_key_version: Option<String>,
|
||||
pub target_master_key_epoch: Option<i64>,
|
||||
}
|
||||
|
||||
pub const MASTER_KEY_CIPHER_CONTRACT: &str = "secret-envelope-v2/aes-256-gcm-hkdf-sha256";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MasterKeyIdentityRecord {
|
||||
pub epoch: i64,
|
||||
pub fingerprint: String,
|
||||
pub cipher_contract: String,
|
||||
pub status: String,
|
||||
pub backup_ref: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub activated_at: Option<OffsetDateTime>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub retired_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MasterKeyIdentityCandidate<'a> {
|
||||
pub epoch: i64,
|
||||
pub fingerprint: &'a str,
|
||||
pub cipher_contract: &'a str,
|
||||
pub observed_at: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MasterKeyRotationRecord {
|
||||
pub id: String,
|
||||
pub source_epoch: i64,
|
||||
pub target_epoch: i64,
|
||||
pub target_fingerprint: String,
|
||||
pub state: String,
|
||||
pub backup_ref: Option<String>,
|
||||
pub checkpoint_secret_id: Option<String>,
|
||||
pub total_secret_versions: i64,
|
||||
pub processed_secret_versions: i64,
|
||||
pub verified_secret_versions: i64,
|
||||
pub failure_code: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MasterKeyRotationStatus {
|
||||
pub active_identity: Option<MasterKeyIdentityRecord>,
|
||||
pub rotations: Vec<MasterKeyRotationRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -170,6 +276,114 @@ pub struct UsageAgentBreakdown {
|
||||
pub p99_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageOutcomeGroup {
|
||||
Success,
|
||||
Upstream,
|
||||
Client,
|
||||
Schema,
|
||||
Crank,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UsageOutcomeBreakdown {
|
||||
pub group: UsageOutcomeGroup,
|
||||
pub execution_error_code: Option<ExecutionErrorCode>,
|
||||
pub calls_total: u64,
|
||||
pub p50_ms: u64,
|
||||
pub p95_ms: u64,
|
||||
pub p99_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AppendProductEventOutcome {
|
||||
Recorded,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProductEventRecord {
|
||||
pub event: ProductEvent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppendProductEventRequest<'a> {
|
||||
pub event: &'a ProductEvent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ListProductEventsQuery<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub kind: Option<ProductEventKind>,
|
||||
pub created_after: OffsetDateTime,
|
||||
pub created_before: OffsetDateTime,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OnboardingPresentationMilestone {
|
||||
Eligible,
|
||||
Started,
|
||||
Resumed,
|
||||
Dismissed,
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecordOnboardingMilestoneRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub event_id: &'a ProductEventId,
|
||||
pub milestone: OnboardingPresentationMilestone,
|
||||
pub idempotency_key: &'a str,
|
||||
pub expected_revision: i64,
|
||||
pub occurred_at: OffsetDateTime,
|
||||
pub eligible_since: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecordOnboardingCompletionRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub event_id: &'a ProductEventId,
|
||||
pub idempotency_key: &'a str,
|
||||
pub expected_revision: i64,
|
||||
pub occurred_at: OffsetDateTime,
|
||||
pub eligible_since: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OnboardingMilestoneResult {
|
||||
pub accepted: bool,
|
||||
pub projection: OnboardingProjection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvocationRetentionStatus {
|
||||
Noop,
|
||||
Completed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InvocationRetentionPolicy {
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub requested_cutoff: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub effective_cutoff: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub usage_preservation_floor: OffsetDateTime,
|
||||
pub preserved_usage_window_days: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InvocationRetentionOutcome {
|
||||
pub status: InvocationRetentionStatus,
|
||||
pub deleted_records: u64,
|
||||
pub policy: InvocationRetentionPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentSummary {
|
||||
pub id: AgentId,
|
||||
@@ -180,6 +394,7 @@ pub struct AgentSummary {
|
||||
pub status: AgentStatus,
|
||||
pub current_draft_version: u32,
|
||||
pub latest_published_version: Option<u32>,
|
||||
pub catalog_revision: i64,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
pub published_at: Option<OffsetDateTime>,
|
||||
@@ -196,6 +411,15 @@ pub struct AgentVersionRecord {
|
||||
pub bindings: Vec<AgentOperationBinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AgentStateExpectation {
|
||||
pub status: AgentStatus,
|
||||
pub current_draft_version: u32,
|
||||
pub latest_published_version: Option<u32>,
|
||||
pub catalog_revision: i64,
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PublishedAgentTool {
|
||||
pub workspace_id: WorkspaceId,
|
||||
@@ -211,6 +435,7 @@ pub struct PublishedAgentTool {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PublishedAgentCatalog {
|
||||
pub agent_version: u32,
|
||||
pub catalog_revision: String,
|
||||
pub tool_selection_policy: ToolSelectionPolicy,
|
||||
pub tools: Vec<PublishedAgentTool>,
|
||||
}
|
||||
@@ -229,6 +454,7 @@ pub struct OperationSummary {
|
||||
pub status: OperationStatus,
|
||||
pub current_draft_version: u32,
|
||||
pub latest_published_version: Option<u32>,
|
||||
pub can_delete: bool,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
pub published_at: Option<OffsetDateTime>,
|
||||
@@ -485,11 +711,16 @@ impl InvocationHistoryWriteOutcome {
|
||||
pub struct ListInvocationLogsQuery<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub level: Option<InvocationLevel>,
|
||||
pub status: Option<InvocationStatus>,
|
||||
pub outcome_group: Option<UsageOutcomeGroup>,
|
||||
pub search_text: Option<&'a str>,
|
||||
pub source: Option<InvocationSource>,
|
||||
pub operation_id: Option<&'a OperationId>,
|
||||
pub agent_id: Option<&'a AgentId>,
|
||||
pub created_after: Option<&'a str>,
|
||||
pub created_before: Option<&'a str>,
|
||||
pub cursor_created_at: Option<&'a str>,
|
||||
pub cursor_id: Option<&'a InvocationLogId>,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
@@ -507,6 +738,7 @@ pub struct UsageQuery<'a> {
|
||||
pub period: UsagePeriod,
|
||||
pub source: Option<InvocationSource>,
|
||||
pub created_after: &'a str,
|
||||
pub created_before: &'a str,
|
||||
pub bucket: UsageBucket,
|
||||
}
|
||||
|
||||
@@ -524,6 +756,13 @@ pub struct CreateVersionRequest<'a> {
|
||||
pub created_by: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OperationStateExpectation {
|
||||
pub current_draft_version: u32,
|
||||
pub status: OperationStatus,
|
||||
pub latest_published_version: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PublishRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
@@ -579,12 +818,22 @@ pub struct CreateAgentDraftVersionRequest<'a> {
|
||||
pub updated_at: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UpdateAgentSummaryRequest<'a> {
|
||||
pub slug: &'a str,
|
||||
pub display_name: &'a str,
|
||||
pub description: &'a str,
|
||||
pub updated_at: &'a OffsetDateTime,
|
||||
pub expected_state: Option<&'a AgentStateExpectation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SaveAgentBindingsRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub agent_id: &'a AgentId,
|
||||
pub agent_version: u32,
|
||||
pub bindings: &'a [AgentOperationBinding],
|
||||
pub expected_state: Option<&'a AgentStateExpectation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -594,6 +843,7 @@ pub struct SaveAgentCatalogConfigRequest<'a> {
|
||||
pub agent_version: u32,
|
||||
pub bindings: &'a [AgentOperationBinding],
|
||||
pub tool_selection_policy: &'a ToolSelectionPolicy,
|
||||
pub expected_state: Option<&'a AgentStateExpectation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -603,6 +853,7 @@ pub struct PublishAgentRequest<'a> {
|
||||
pub version: u32,
|
||||
pub published_at: &'a OffsetDateTime,
|
||||
pub published_by: Option<&'a str>,
|
||||
pub expected_state: Option<&'a AgentStateExpectation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -633,9 +884,12 @@ pub struct DecideApprovalRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub agent_id: &'a AgentId,
|
||||
pub approval_id: &'a ApprovalRequestId,
|
||||
pub operation_id: &'a OperationId,
|
||||
pub operation_version: u32,
|
||||
pub request_payload: &'a Value,
|
||||
pub status: ApprovalRequestStatus,
|
||||
pub decided_at: OffsetDateTime,
|
||||
pub decided_by_key_id: &'a PlatformApiKeyId,
|
||||
pub decided_by_key_id: Option<&'a PlatformApiKeyId>,
|
||||
pub response_payload: Option<Value>,
|
||||
pub decision_note: Option<&'a str>,
|
||||
}
|
||||
@@ -663,6 +917,7 @@ pub struct CreateSecretRequest<'a> {
|
||||
pub secret: &'a Secret,
|
||||
pub ciphertext: &'a str,
|
||||
pub key_version: &'a str,
|
||||
pub master_key_epoch: i64,
|
||||
pub created_by: Option<&'a crank_core::UserId>,
|
||||
}
|
||||
|
||||
@@ -672,6 +927,7 @@ pub struct RotateSecretRequest<'a> {
|
||||
pub secret_id: &'a SecretId,
|
||||
pub ciphertext: &'a str,
|
||||
pub key_version: &'a str,
|
||||
pub master_key_epoch: i64,
|
||||
pub created_at: &'a OffsetDateTime,
|
||||
pub updated_at: &'a OffsetDateTime,
|
||||
pub created_by: Option<&'a crank_core::UserId>,
|
||||
|
||||
@@ -1,47 +1,12 @@
|
||||
use super::*;
|
||||
use crate::model::{AgentStateExpectation, UpdateAgentSummaryRequest};
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn get_published_agent_catalog_by_slug(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<PublishedAgentCatalog, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
pa.version,
|
||||
av.tool_selection_policy_json
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
join agent_versions av on av.agent_id = a.id and av.version = pa.version
|
||||
where w.slug = $1 and a.slug = $2",
|
||||
)
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or_else(|| RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug: workspace_slug.to_owned(),
|
||||
agent_slug: agent_slug.to_owned(),
|
||||
})?;
|
||||
let tools = self
|
||||
.get_published_agent_tools_by_slug(workspace_slug, agent_slug)
|
||||
.await?;
|
||||
|
||||
Ok(PublishedAgentCatalog {
|
||||
agent_version: from_db_version(row.try_get("version")?, "agent_version")?,
|
||||
tool_selection_policy: deserialize_json_value(
|
||||
row.try_get("tool_selection_policy_json")?,
|
||||
)?,
|
||||
tools,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_agents(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<Vec<AgentSummary>, RegistryError> {
|
||||
let rows = sqlx::query!(
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
workspace_id,
|
||||
@@ -51,31 +16,33 @@ impl PostgresRegistry {
|
||||
status,
|
||||
current_draft_version,
|
||||
latest_published_version,
|
||||
created_at as \"created_at!: time::OffsetDateTime\",
|
||||
updated_at as \"updated_at!: time::OffsetDateTime\",
|
||||
published_at as \"published_at: time::OffsetDateTime\"
|
||||
catalog_revision,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at
|
||||
from agents
|
||||
where workspace_id = $1
|
||||
order by slug asc",
|
||||
workspace_id.as_str(),
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
build_agent_summary(
|
||||
row.id,
|
||||
row.workspace_id,
|
||||
row.slug,
|
||||
row.display_name,
|
||||
row.description,
|
||||
row.status,
|
||||
row.current_draft_version,
|
||||
row.latest_published_version,
|
||||
row.created_at,
|
||||
row.updated_at,
|
||||
row.published_at,
|
||||
row.try_get("id")?,
|
||||
row.try_get("workspace_id")?,
|
||||
row.try_get("slug")?,
|
||||
row.try_get("display_name")?,
|
||||
row.try_get("description")?,
|
||||
row.try_get("status")?,
|
||||
row.try_get("current_draft_version")?,
|
||||
row.try_get("latest_published_version")?,
|
||||
row.try_get("catalog_revision")?,
|
||||
row.try_get("created_at")?,
|
||||
row.try_get("updated_at")?,
|
||||
row.try_get("published_at")?,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
@@ -86,7 +53,7 @@ impl PostgresRegistry {
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<Option<AgentSummary>, RegistryError> {
|
||||
let row = sqlx::query!(
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
workspace_id,
|
||||
@@ -96,30 +63,32 @@ impl PostgresRegistry {
|
||||
status,
|
||||
current_draft_version,
|
||||
latest_published_version,
|
||||
created_at as \"created_at!: time::OffsetDateTime\",
|
||||
updated_at as \"updated_at!: time::OffsetDateTime\",
|
||||
published_at as \"published_at: time::OffsetDateTime\"
|
||||
catalog_revision,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at
|
||||
from agents
|
||||
where workspace_id = $1 and id = $2",
|
||||
workspace_id.as_str(),
|
||||
agent_id.as_str(),
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| {
|
||||
build_agent_summary(
|
||||
row.id,
|
||||
row.workspace_id,
|
||||
row.slug,
|
||||
row.display_name,
|
||||
row.description,
|
||||
row.status,
|
||||
row.current_draft_version,
|
||||
row.latest_published_version,
|
||||
row.created_at,
|
||||
row.updated_at,
|
||||
row.published_at,
|
||||
row.try_get("id")?,
|
||||
row.try_get("workspace_id")?,
|
||||
row.try_get("slug")?,
|
||||
row.try_get("display_name")?,
|
||||
row.try_get("description")?,
|
||||
row.try_get("status")?,
|
||||
row.try_get("current_draft_version")?,
|
||||
row.try_get("latest_published_version")?,
|
||||
row.try_get("catalog_revision")?,
|
||||
row.try_get("created_at")?,
|
||||
row.try_get("updated_at")?,
|
||||
row.try_get("published_at")?,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
@@ -162,6 +131,7 @@ impl PostgresRegistry {
|
||||
insert_agent_version_row(&mut tx, request.version).await?;
|
||||
replace_agent_bindings_rows(
|
||||
&mut tx,
|
||||
&request.agent.workspace_id,
|
||||
&request.agent.id,
|
||||
request.version.version,
|
||||
request.bindings,
|
||||
@@ -198,6 +168,7 @@ impl PostgresRegistry {
|
||||
insert_agent_version_row(&mut tx, request.version).await?;
|
||||
replace_agent_bindings_rows(
|
||||
&mut tx,
|
||||
request.workspace_id,
|
||||
request.agent_id,
|
||||
request.version.version,
|
||||
request.bindings,
|
||||
@@ -268,19 +239,19 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: SaveAgentBindingsRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
if self
|
||||
.get_agent_summary(request.workspace_id, request.agent_id)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
request.workspace_id,
|
||||
request.agent_id,
|
||||
request.expected_state,
|
||||
)
|
||||
.await?;
|
||||
Self::reject_published_agent_version(&mut tx, request.agent_id, request.agent_version)
|
||||
.await?;
|
||||
replace_agent_bindings_rows(
|
||||
&mut tx,
|
||||
request.workspace_id,
|
||||
request.agent_id,
|
||||
request.agent_version,
|
||||
request.bindings,
|
||||
@@ -294,17 +265,16 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: SaveAgentCatalogConfigRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
if self
|
||||
.get_agent_summary(request.workspace_id, request.agent_id)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
request.workspace_id,
|
||||
request.agent_id,
|
||||
request.expected_state,
|
||||
)
|
||||
.await?;
|
||||
Self::reject_published_agent_version(&mut tx, request.agent_id, request.agent_version)
|
||||
.await?;
|
||||
let updated = sqlx::query(
|
||||
"update agent_versions
|
||||
set tool_selection_policy_json = $3
|
||||
@@ -323,24 +293,124 @@ impl PostgresRegistry {
|
||||
}
|
||||
replace_agent_bindings_rows(
|
||||
&mut tx,
|
||||
request.workspace_id,
|
||||
request.agent_id,
|
||||
request.agent_version,
|
||||
request.bindings,
|
||||
)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update agents
|
||||
set updated_at = now()
|
||||
where id = $1 and workspace_id = $2",
|
||||
)
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(request.workspace_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn lock_agent_and_validate_expected_state(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
status,
|
||||
current_draft_version,
|
||||
latest_published_version,
|
||||
catalog_revision,
|
||||
updated_at
|
||||
from agents
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
if let Some(expected) = expected_state {
|
||||
let status = deserialize_enum_text::<AgentStatus>(row.try_get("status")?, "status")?;
|
||||
let current_draft_version = from_db_version(
|
||||
row.try_get("current_draft_version")?,
|
||||
"current_draft_version",
|
||||
)?;
|
||||
let latest_published_version = row
|
||||
.try_get::<Option<i32>, _>("latest_published_version")?
|
||||
.map(|value| from_db_version(value, "latest_published_version"))
|
||||
.transpose()?;
|
||||
let catalog_revision: i64 = row.try_get("catalog_revision")?;
|
||||
let updated_at: time::OffsetDateTime = row.try_get("updated_at")?;
|
||||
|
||||
if status != expected.status
|
||||
|| current_draft_version != expected.current_draft_version
|
||||
|| latest_published_version != expected.latest_published_version
|
||||
|| catalog_revision != expected.catalog_revision
|
||||
|| updated_at != expected.updated_at
|
||||
{
|
||||
return Err(RegistryError::AgentStaleRevision {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reject_published_agent_version(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
agent_id: &AgentId,
|
||||
version: u32,
|
||||
) -> Result<(), RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select status
|
||||
from agent_versions
|
||||
where agent_id = $1 and version = $2
|
||||
for update",
|
||||
)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(to_db_version(version))
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
let status = deserialize_enum_text::<AgentStatus>(row.try_get("status")?, "status")?;
|
||||
if status == AgentStatus::Published {
|
||||
return Err(RegistryError::ImmutableAgentVersion {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
version,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_agent_summary(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
slug: &str,
|
||||
display_name: &str,
|
||||
description: &str,
|
||||
updated_at: &time::OffsetDateTime,
|
||||
update: UpdateAgentSummaryRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
update.expected_state,
|
||||
)
|
||||
.await?;
|
||||
let result = sqlx::query(
|
||||
"update agents
|
||||
set slug = $3,
|
||||
@@ -351,11 +421,11 @@ impl PostgresRegistry {
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.bind(slug)
|
||||
.bind(display_name)
|
||||
.bind(description)
|
||||
.bind(updated_at)
|
||||
.execute(&self.pool)
|
||||
.bind(update.slug)
|
||||
.bind(update.display_name)
|
||||
.bind(update.description)
|
||||
.bind(update.updated_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
@@ -364,6 +434,7 @@ impl PostgresRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -371,19 +442,36 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query("delete from agents where workspace_id = $1 and id = $2")
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
expected_state,
|
||||
)
|
||||
.await?;
|
||||
let result = sqlx::query(
|
||||
"delete from agents
|
||||
where workspace_id = $1
|
||||
and id = $2
|
||||
and latest_published_version is null",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
return Err(RegistryError::InvalidAgentTransition {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
from: "published".to_owned(),
|
||||
action: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -391,37 +479,102 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: PublishAgentRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
if self
|
||||
.get_agent_version(request.workspace_id, request.agent_id, request.version)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let Some(agent_row) = sqlx::query(
|
||||
"select status, current_draft_version, latest_published_version, catalog_revision, updated_at
|
||||
from agents
|
||||
where id = $1 and workspace_id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(request.workspace_id.as_str())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
else {
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
if let Some(expected) = request.expected_state {
|
||||
let status =
|
||||
deserialize_enum_text::<AgentStatus>(agent_row.try_get("status")?, "status")?;
|
||||
let expected_current_draft_version = from_db_version(
|
||||
agent_row.try_get("current_draft_version")?,
|
||||
"current_draft_version",
|
||||
)?;
|
||||
let expected_latest_published_version = agent_row
|
||||
.try_get::<Option<i32>, _>("latest_published_version")?
|
||||
.map(|value| from_db_version(value, "latest_published_version"))
|
||||
.transpose()?;
|
||||
let expected_catalog_revision: i64 = agent_row.try_get("catalog_revision")?;
|
||||
let expected_updated_at: time::OffsetDateTime = agent_row.try_get("updated_at")?;
|
||||
if status != expected.status
|
||||
|| expected_current_draft_version != expected.current_draft_version
|
||||
|| expected_latest_published_version != expected.latest_published_version
|
||||
|| expected_catalog_revision != expected.catalog_revision
|
||||
|| expected_updated_at != expected.updated_at
|
||||
{
|
||||
return Err(RegistryError::AgentStaleRevision {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let agent_status =
|
||||
deserialize_enum_text::<AgentStatus>(agent_row.try_get("status")?, "status")?;
|
||||
if agent_status == AgentStatus::Archived {
|
||||
return Err(RegistryError::InvalidAgentTransition {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
from: "archived".to_owned(),
|
||||
action: "publish",
|
||||
});
|
||||
}
|
||||
let current_draft_version = from_db_version(
|
||||
agent_row.try_get("current_draft_version")?,
|
||||
"current_draft_version",
|
||||
)?;
|
||||
if request.version != current_draft_version {
|
||||
return Err(RegistryError::InvalidAgentVersionSequence {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
expected: current_draft_version,
|
||||
actual: request.version,
|
||||
});
|
||||
}
|
||||
let latest_published_version = agent_row
|
||||
.try_get::<Option<i32>, _>("latest_published_version")?
|
||||
.map(|value| from_db_version(value, "latest_published_version"))
|
||||
.transpose()?;
|
||||
if latest_published_version.is_some_and(|latest| request.version < latest) {
|
||||
return Err(RegistryError::InvalidAgentVersionSequence {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
expected: latest_published_version.unwrap_or(request.version),
|
||||
actual: request.version,
|
||||
});
|
||||
}
|
||||
let catalog_revision: i64 = agent_row.try_get("catalog_revision")?;
|
||||
let next_catalog_revision = catalog_revision
|
||||
.checked_add(1)
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or(RegistryError::InvalidNumericValue {
|
||||
field: "catalog_revision",
|
||||
value: catalog_revision,
|
||||
})?;
|
||||
|
||||
let version_exists = sqlx::query(
|
||||
"select 1
|
||||
from agent_versions
|
||||
where agent_id = $1 and version = $2",
|
||||
)
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(to_db_version(request.version))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.is_some();
|
||||
if !version_exists {
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query(
|
||||
"insert into published_agents (
|
||||
agent_id,
|
||||
version,
|
||||
published_at,
|
||||
published_by
|
||||
) values ($1, $2, $3::timestamptz, $4)
|
||||
on conflict(agent_id) do update set
|
||||
version = excluded.version,
|
||||
published_at = excluded.published_at,
|
||||
published_by = excluded.published_by",
|
||||
)
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(to_db_version(request.version))
|
||||
.bind(request.published_at)
|
||||
.bind(request.published_by)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"update agent_versions
|
||||
set status = $1
|
||||
@@ -433,16 +586,40 @@ impl PostgresRegistry {
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"insert into published_agents (
|
||||
agent_id,
|
||||
version,
|
||||
catalog_revision,
|
||||
published_at,
|
||||
published_by
|
||||
) values ($1, $2, $3, $4::timestamptz, $5)
|
||||
on conflict(agent_id) do update set
|
||||
version = excluded.version,
|
||||
catalog_revision = excluded.catalog_revision,
|
||||
published_at = excluded.published_at,
|
||||
published_by = excluded.published_by",
|
||||
)
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(to_db_version(request.version))
|
||||
.bind(next_catalog_revision)
|
||||
.bind(request.published_at)
|
||||
.bind(request.published_by)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"update agents
|
||||
set status = $1,
|
||||
latest_published_version = $2,
|
||||
published_at = $3::timestamptz,
|
||||
updated_at = $4::timestamptz
|
||||
where id = $5 and workspace_id = $6",
|
||||
catalog_revision = $3,
|
||||
published_at = $4::timestamptz,
|
||||
updated_at = $5::timestamptz
|
||||
where id = $6 and workspace_id = $7",
|
||||
)
|
||||
.bind(serialize_enum_text(&AgentStatus::Published, "status")?)
|
||||
.bind(to_db_version(request.version))
|
||||
.bind(next_catalog_revision)
|
||||
.bind(request.published_at)
|
||||
.bind(request.published_at)
|
||||
.bind(request.agent_id.as_str())
|
||||
@@ -459,38 +636,55 @@ impl PostgresRegistry {
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
updated_at: &time::OffsetDateTime,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query("delete from published_agents where agent_id = $1")
|
||||
.bind(agent_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"update agent_versions
|
||||
set status = $1
|
||||
where agent_id = $2
|
||||
and version = (
|
||||
select current_draft_version
|
||||
from agents
|
||||
where id = $2 and workspace_id = $3
|
||||
)",
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
expected_state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let current_status = sqlx::query(
|
||||
"select status, catalog_revision
|
||||
from agents
|
||||
where id = $1 and workspace_id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(serialize_enum_text(&AgentStatus::Draft, "status")?)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let status =
|
||||
deserialize_enum_text::<AgentStatus>(current_status.try_get("status")?, "status")?;
|
||||
if status != AgentStatus::Published {
|
||||
return Err(RegistryError::InvalidAgentTransition {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
from: serialize_enum_text(&status, "status")?,
|
||||
action: "unpublish",
|
||||
});
|
||||
}
|
||||
let catalog_revision: i64 = current_status.try_get("catalog_revision")?;
|
||||
let next_catalog_revision = catalog_revision
|
||||
.checked_add(1)
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or(RegistryError::InvalidNumericValue {
|
||||
field: "catalog_revision",
|
||||
value: catalog_revision,
|
||||
})?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"update agents
|
||||
set status = $1,
|
||||
catalog_revision = $2,
|
||||
published_at = null,
|
||||
updated_at = $2::timestamptz
|
||||
where id = $3 and workspace_id = $4",
|
||||
updated_at = $3::timestamptz
|
||||
where id = $4 and workspace_id = $5",
|
||||
)
|
||||
.bind(serialize_enum_text(&AgentStatus::Draft, "status")?)
|
||||
.bind(next_catalog_revision)
|
||||
.bind(updated_at)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
@@ -512,38 +706,55 @@ impl PostgresRegistry {
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
updated_at: &time::OffsetDateTime,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query("delete from published_agents where agent_id = $1")
|
||||
.bind(agent_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"update agent_versions
|
||||
set status = $1
|
||||
where agent_id = $2
|
||||
and version = (
|
||||
select current_draft_version
|
||||
from agents
|
||||
where id = $2 and workspace_id = $3
|
||||
)",
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
expected_state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let current_status = sqlx::query(
|
||||
"select status, catalog_revision
|
||||
from agents
|
||||
where id = $1 and workspace_id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(serialize_enum_text(&AgentStatus::Archived, "status")?)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let status =
|
||||
deserialize_enum_text::<AgentStatus>(current_status.try_get("status")?, "status")?;
|
||||
if status == AgentStatus::Archived {
|
||||
return Err(RegistryError::InvalidAgentTransition {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
from: "archived".to_owned(),
|
||||
action: "archive",
|
||||
});
|
||||
}
|
||||
let catalog_revision: i64 = current_status.try_get("catalog_revision")?;
|
||||
let next_catalog_revision = catalog_revision
|
||||
.checked_add(1)
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or(RegistryError::InvalidNumericValue {
|
||||
field: "catalog_revision",
|
||||
value: catalog_revision,
|
||||
})?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"update agents
|
||||
set status = $1,
|
||||
catalog_revision = $2,
|
||||
published_at = null,
|
||||
updated_at = $2::timestamptz
|
||||
where id = $3 and workspace_id = $4",
|
||||
updated_at = $3::timestamptz
|
||||
where id = $4 and workspace_id = $5",
|
||||
)
|
||||
.bind(serialize_enum_text(&AgentStatus::Archived, "status")?)
|
||||
.bind(next_catalog_revision)
|
||||
.bind(updated_at)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
@@ -559,123 +770,4 @@ impl PostgresRegistry {
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_published_agent_tools_by_slug(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
|
||||
let rows = sqlx::query!(
|
||||
"select
|
||||
w.id as workspace_id,
|
||||
w.slug as workspace_slug,
|
||||
a.id as agent_id,
|
||||
a.slug as agent_slug,
|
||||
b.tool_name,
|
||||
b.tool_title,
|
||||
coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",
|
||||
o.id,
|
||||
o.name,
|
||||
o.display_name,
|
||||
o.category,
|
||||
o.protocol,
|
||||
o.security_level,
|
||||
o.created_at as \"operation_created_at!: time::OffsetDateTime\",
|
||||
o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",
|
||||
o.published_at as \"operation_published_at: time::OffsetDateTime\",
|
||||
ov.version,
|
||||
ov.status,
|
||||
ov.target_json,
|
||||
ov.input_schema_json,
|
||||
ov.output_schema_json,
|
||||
ov.input_mapping_json,
|
||||
ov.output_mapping_json,
|
||||
ov.execution_config_json,
|
||||
ov.tool_description_json,
|
||||
ov.samples_json,
|
||||
ov.generated_draft_json,
|
||||
ov.config_export_json,
|
||||
ov.wizard_state_json,
|
||||
ov.change_note,
|
||||
ov.created_at as \"created_at!: time::OffsetDateTime\",
|
||||
ov.created_by
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version
|
||||
join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version
|
||||
join operations o on o.id = ov.operation_id and o.workspace_id = w.id
|
||||
where w.slug = $1 and a.slug = $2 and b.enabled = true
|
||||
order by b.tool_name asc",
|
||||
workspace_slug,
|
||||
agent_slug,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
let exists = sqlx::query!(
|
||||
"select 1 as \"present!\"
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
where w.slug = $1 and a.slug = $2",
|
||||
workspace_slug,
|
||||
agent_slug,
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if exists.is_none() {
|
||||
return Err(RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug: workspace_slug.to_owned(),
|
||||
agent_slug: agent_slug.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let workspace_id = row.workspace_id.clone();
|
||||
build_published_agent_tool(
|
||||
row.workspace_id,
|
||||
row.workspace_slug,
|
||||
row.agent_id,
|
||||
row.agent_slug,
|
||||
row.tool_name,
|
||||
row.tool_title,
|
||||
row.tool_description,
|
||||
build_operation_version_record(
|
||||
row.id,
|
||||
workspace_id,
|
||||
row.name,
|
||||
row.display_name,
|
||||
row.category,
|
||||
row.protocol,
|
||||
row.security_level,
|
||||
row.operation_created_at,
|
||||
row.operation_updated_at,
|
||||
row.operation_published_at,
|
||||
row.version,
|
||||
row.status,
|
||||
row.target_json,
|
||||
row.input_schema_json,
|
||||
row.output_schema_json,
|
||||
row.input_mapping_json,
|
||||
row.output_mapping_json,
|
||||
row.execution_config_json,
|
||||
row.tool_description_json,
|
||||
row.samples_json,
|
||||
row.generated_draft_json,
|
||||
row.config_export_json,
|
||||
row.wizard_state_json,
|
||||
row.change_note,
|
||||
row.created_at,
|
||||
row.created_by,
|
||||
)?
|
||||
.snapshot,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use super::*;
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn get_published_agent_catalog_by_slug(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<PublishedAgentCatalog, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("set transaction isolation level repeatable read")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
pa.version,
|
||||
pa.catalog_revision,
|
||||
av.tool_selection_policy_json
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
join agent_versions av on av.agent_id = a.id and av.version = pa.version
|
||||
where w.slug = $1
|
||||
and a.slug = $2
|
||||
and a.status = 'published'
|
||||
and av.status = 'published'",
|
||||
)
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug: workspace_slug.to_owned(),
|
||||
agent_slug: agent_slug.to_owned(),
|
||||
})?;
|
||||
let tools = query_published_agent_tools(&mut tx, workspace_slug, agent_slug).await?;
|
||||
|
||||
let agent_version = from_db_version(row.try_get("version")?, "agent_version")?;
|
||||
let catalog_revision: i64 = row.try_get("catalog_revision")?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(PublishedAgentCatalog {
|
||||
agent_version,
|
||||
catalog_revision: format!(
|
||||
"agent-version-{agent_version}-catalog-revision-{catalog_revision}"
|
||||
),
|
||||
tool_selection_policy: deserialize_json_value(
|
||||
row.try_get("tool_selection_policy_json")?,
|
||||
)?,
|
||||
tools,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_published_agent_tools_by_slug(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
|
||||
let rows = published_agent_tools_query()
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
let exists = sqlx::query(
|
||||
"select 1 as present
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
where w.slug = $1
|
||||
and a.slug = $2
|
||||
and a.status = 'published'",
|
||||
)
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if exists.is_none() {
|
||||
return Err(RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug: workspace_slug.to_owned(),
|
||||
agent_slug: agent_slug.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
published_agent_tools_from_rows(rows)
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_published_agent_tools(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
|
||||
let rows = published_agent_tools_query()
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
published_agent_tools_from_rows(rows)
|
||||
}
|
||||
|
||||
fn published_agent_tools_query()
|
||||
-> sqlx::query::Query<'static, sqlx::Postgres, sqlx::postgres::PgArguments> {
|
||||
sqlx::query(
|
||||
"select
|
||||
w.id as workspace_id,
|
||||
w.slug as workspace_slug,
|
||||
a.id as agent_id,
|
||||
a.slug as agent_slug,
|
||||
b.tool_name,
|
||||
b.tool_title,
|
||||
coalesce(b.tool_description_override, ov.tool_description_json->>'description') as tool_description,
|
||||
o.id,
|
||||
ov.name,
|
||||
ov.display_name,
|
||||
ov.category,
|
||||
ov.protocol,
|
||||
ov.security_level,
|
||||
ov.created_at as operation_created_at,
|
||||
ov.created_at as operation_updated_at,
|
||||
ov.published_at as operation_published_at,
|
||||
ov.version,
|
||||
ov.status,
|
||||
ov.target_json,
|
||||
ov.input_schema_json,
|
||||
ov.output_schema_json,
|
||||
ov.input_mapping_json,
|
||||
ov.output_mapping_json,
|
||||
ov.execution_config_json,
|
||||
ov.tool_description_json,
|
||||
ov.samples_json,
|
||||
ov.generated_draft_json,
|
||||
ov.config_export_json,
|
||||
ov.wizard_state_json,
|
||||
ov.change_note,
|
||||
ov.created_at as created_at,
|
||||
ov.created_by
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version
|
||||
join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version
|
||||
join operations o on o.id = ov.operation_id and o.workspace_id = w.id
|
||||
where w.slug = $1
|
||||
and a.slug = $2
|
||||
and a.status = 'published'
|
||||
and ov.status = 'published'
|
||||
and b.enabled = true
|
||||
order by b.tool_name asc",
|
||||
)
|
||||
}
|
||||
|
||||
fn published_agent_tools_from_rows(
|
||||
rows: Vec<sqlx::postgres::PgRow>,
|
||||
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let workspace_id: String = row.try_get("workspace_id")?;
|
||||
build_published_agent_tool(
|
||||
workspace_id.clone(),
|
||||
row.try_get("workspace_slug")?,
|
||||
row.try_get("agent_id")?,
|
||||
row.try_get("agent_slug")?,
|
||||
row.try_get("tool_name")?,
|
||||
row.try_get("tool_title")?,
|
||||
row.try_get("tool_description")?,
|
||||
build_operation_version_record(
|
||||
row.try_get("id")?,
|
||||
workspace_id,
|
||||
row.try_get("name")?,
|
||||
row.try_get("display_name")?,
|
||||
row.try_get("category")?,
|
||||
row.try_get("protocol")?,
|
||||
row.try_get("security_level")?,
|
||||
row.try_get("operation_created_at")?,
|
||||
row.try_get("operation_updated_at")?,
|
||||
row.try_get("operation_published_at")?,
|
||||
row.try_get("version")?,
|
||||
row.try_get("status")?,
|
||||
row.try_get("target_json")?,
|
||||
row.try_get("input_schema_json")?,
|
||||
row.try_get("output_schema_json")?,
|
||||
row.try_get("input_mapping_json")?,
|
||||
row.try_get("output_mapping_json")?,
|
||||
row.try_get("execution_config_json")?,
|
||||
row.try_get("tool_description_json")?,
|
||||
row.try_get("samples_json")?,
|
||||
row.try_get("generated_draft_json")?,
|
||||
row.try_get("config_export_json")?,
|
||||
row.try_get("wizard_state_json")?,
|
||||
row.try_get("change_note")?,
|
||||
row.try_get("created_at")?,
|
||||
row.try_get("created_by")?,
|
||||
)?
|
||||
.snapshot,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -274,9 +274,18 @@ impl PostgresRegistry {
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(error))
|
||||
if error.constraint() == Some("platform_api_keys_workspace_name_idx") =>
|
||||
if matches!(
|
||||
error.constraint(),
|
||||
Some(
|
||||
"platform_api_keys_workspace_name_idx"
|
||||
| "platform_api_keys_workspace_name_active_idx"
|
||||
)
|
||||
) =>
|
||||
{
|
||||
Err(RegistryError::Storage(sqlx::Error::Database(error)))
|
||||
Err(RegistryError::PlatformApiKeyNameAlreadyExists {
|
||||
workspace_id: request.api_key.workspace_id.as_str().to_owned(),
|
||||
name: request.api_key.name.clone(),
|
||||
})
|
||||
}
|
||||
Err(error) => Err(RegistryError::Storage(error)),
|
||||
}
|
||||
@@ -288,11 +297,22 @@ impl PostgresRegistry {
|
||||
key_id: &PlatformApiKeyId,
|
||||
revoked_at: &time::OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = $2::timestamptz
|
||||
where workspace_id = $3 and id = $4",
|
||||
let row = sqlx::query(
|
||||
"with target as (
|
||||
select id
|
||||
from platform_api_keys
|
||||
where workspace_id = $3 and id = $4
|
||||
), updated as (
|
||||
update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = $2::timestamptz
|
||||
where workspace_id = $3
|
||||
and id = $4
|
||||
and status = 'active'
|
||||
returning id
|
||||
)
|
||||
select exists(select 1 from target) as exists,
|
||||
exists(select 1 from updated) as updated",
|
||||
)
|
||||
.bind(serialize_enum_text(
|
||||
&PlatformApiKeyStatus::Revoked,
|
||||
@@ -301,14 +321,19 @@ impl PostgresRegistry {
|
||||
.bind(revoked_at)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("updated") {
|
||||
return Err(RegistryError::PlatformApiKeyInactive {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -320,11 +345,23 @@ impl PostgresRegistry {
|
||||
key_id: &PlatformApiKeyId,
|
||||
revoked_at: &time::OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = $2::timestamptz
|
||||
where workspace_id = $3 and agent_id = $4 and id = $5",
|
||||
let row = sqlx::query(
|
||||
"with target as (
|
||||
select id
|
||||
from platform_api_keys
|
||||
where workspace_id = $3 and agent_id = $4 and id = $5
|
||||
), updated as (
|
||||
update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = $2::timestamptz
|
||||
where workspace_id = $3
|
||||
and agent_id = $4
|
||||
and id = $5
|
||||
and status = 'active'
|
||||
returning id
|
||||
)
|
||||
select exists(select 1 from target) as exists,
|
||||
exists(select 1 from updated) as updated",
|
||||
)
|
||||
.bind(serialize_enum_text(
|
||||
&PlatformApiKeyStatus::Revoked,
|
||||
@@ -334,14 +371,19 @@ impl PostgresRegistry {
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("updated") {
|
||||
return Err(RegistryError::PlatformApiKeyInactive {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -351,18 +393,42 @@ impl PostgresRegistry {
|
||||
workspace_id: &WorkspaceId,
|
||||
key_id: &PlatformApiKeyId,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result =
|
||||
sqlx::query("delete from platform_api_keys where workspace_id = $1 and id = $2")
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let row = sqlx::query(
|
||||
"with target as (
|
||||
select id
|
||||
from platform_api_keys
|
||||
where workspace_id = $2 and id = $3
|
||||
), updated as (
|
||||
update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where workspace_id = $2
|
||||
and id = $3
|
||||
and status <> 'deleted'
|
||||
returning id
|
||||
)
|
||||
select exists(select 1 from target) as exists,
|
||||
exists(select 1 from updated) as updated",
|
||||
)
|
||||
.bind(serialize_enum_text(
|
||||
&PlatformApiKeyStatus::Deleted,
|
||||
"status",
|
||||
)?)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("updated") {
|
||||
return Err(RegistryError::PlatformApiKeyInactive {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -373,20 +439,44 @@ impl PostgresRegistry {
|
||||
agent_id: &AgentId,
|
||||
key_id: &PlatformApiKeyId,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"delete from platform_api_keys where workspace_id = $1 and agent_id = $2 and id = $3",
|
||||
let row = sqlx::query(
|
||||
"with target as (
|
||||
select id
|
||||
from platform_api_keys
|
||||
where workspace_id = $2 and agent_id = $3 and id = $4
|
||||
), updated as (
|
||||
update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where workspace_id = $2
|
||||
and agent_id = $3
|
||||
and id = $4
|
||||
and status <> 'deleted'
|
||||
returning id
|
||||
)
|
||||
select exists(select 1 from target) as exists,
|
||||
exists(select 1 from updated) as updated",
|
||||
)
|
||||
.bind(serialize_enum_text(
|
||||
&PlatformApiKeyStatus::Deleted,
|
||||
"status",
|
||||
)?)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("updated") {
|
||||
return Err(RegistryError::PlatformApiKeyInactive {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -397,23 +487,31 @@ impl PostgresRegistry {
|
||||
key_id: &PlatformApiKeyId,
|
||||
used_at: &time::OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"with target as (
|
||||
select id
|
||||
let row = sqlx::query(
|
||||
"with target as materialized (
|
||||
select id,
|
||||
last_used_at,
|
||||
status = 'active'
|
||||
and (expires_at is null or expires_at > now()) as active
|
||||
from platform_api_keys
|
||||
where workspace_id = $1 and id = $2
|
||||
for update
|
||||
), updated as (
|
||||
update platform_api_keys
|
||||
update platform_api_keys as api_key
|
||||
set last_used_at = $3::timestamptz
|
||||
where workspace_id = $1
|
||||
and id = $2
|
||||
from target
|
||||
where api_key.workspace_id = $1
|
||||
and api_key.id = target.id
|
||||
and target.active
|
||||
and (
|
||||
last_used_at is null
|
||||
or last_used_at < $3::timestamptz - interval '1 minute'
|
||||
target.last_used_at is null
|
||||
or target.last_used_at < $3::timestamptz - interval '1 minute'
|
||||
)
|
||||
returning id
|
||||
returning api_key.id
|
||||
)
|
||||
select exists(select 1 from target)",
|
||||
select
|
||||
exists(select 1 from target) as exists,
|
||||
coalesce((select active from target), false) as active",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
@@ -421,11 +519,16 @@ impl PostgresRegistry {
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if !exists {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("active") {
|
||||
return Err(RegistryError::PlatformApiKeyInactive {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -11,13 +11,15 @@ impl PostgresRegistry {
|
||||
sqlx::query(
|
||||
"update approval_requests
|
||||
set status = 'expired'
|
||||
where agent_id = $1
|
||||
and operation_id = $2
|
||||
and operation_version = $3
|
||||
and request_fingerprint = $4
|
||||
where workspace_id = $1
|
||||
and agent_id = $2
|
||||
and operation_id = $3
|
||||
and operation_version = $4
|
||||
and request_fingerprint = $5
|
||||
and status = 'pending'
|
||||
and expires_at <= $5",
|
||||
and expires_at <= $6",
|
||||
)
|
||||
.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))
|
||||
@@ -34,6 +36,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -44,15 +48,22 @@ impl PostgresRegistry {
|
||||
request_fingerprint
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||
$9, $10::timestamptz, $11::timestamptz, $12::timestamptz,
|
||||
$13, $14, $15
|
||||
$9, $10, $11, $12::timestamptz, $13::timestamptz,
|
||||
$14::timestamptz, $15, $16, $17
|
||||
)
|
||||
on conflict (
|
||||
workspace_id,
|
||||
agent_id,
|
||||
operation_id,
|
||||
operation_version,
|
||||
request_fingerprint
|
||||
)
|
||||
on conflict (agent_id, operation_id, operation_version, request_fingerprint)
|
||||
where status = 'pending' and request_fingerprint is not null
|
||||
do update set request_fingerprint = excluded.request_fingerprint
|
||||
returning
|
||||
id, workspace_id, agent_id, operation_id, operation_version,
|
||||
status, risk_level, request_payload_json, response_payload_json,
|
||||
status, risk_level, request_id, trace_id, request_payload_json,
|
||||
response_payload_json,
|
||||
created_at, expires_at, decided_at, decided_by_key_id, decision_note",
|
||||
)
|
||||
.bind(request.approval.id.as_str())
|
||||
@@ -68,6 +79,8 @@ impl PostgresRegistry {
|
||||
&request.approval.risk_level,
|
||||
"approval_risk_level",
|
||||
)?)
|
||||
.bind(request.approval.request_id.as_deref())
|
||||
.bind(request.approval.trace_id.as_deref())
|
||||
.bind(Json(&request.approval.request_payload))
|
||||
.bind(request.approval.response_payload.as_ref().map(Json))
|
||||
.bind(request.approval.created_at)
|
||||
@@ -103,6 +116,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -140,6 +155,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -179,6 +196,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -215,6 +234,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -239,6 +260,7 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: DecideApprovalRequest<'_>,
|
||||
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||
let expected_fingerprint = approval_request_fingerprint(request.request_payload)?;
|
||||
let row = sqlx::query(
|
||||
"update approval_requests
|
||||
set status = $1,
|
||||
@@ -249,6 +271,9 @@ impl PostgresRegistry {
|
||||
where workspace_id = $6
|
||||
and agent_id = $7
|
||||
and id = $8
|
||||
and operation_id = $9
|
||||
and operation_version = $10
|
||||
and request_fingerprint = $11
|
||||
and status = 'pending'
|
||||
and expires_at > $3::timestamptz
|
||||
returning
|
||||
@@ -259,6 +284,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -270,11 +297,14 @@ impl PostgresRegistry {
|
||||
.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.decided_by_key_id.map(PlatformApiKeyId::as_str))
|
||||
.bind(request.decision_note)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(request.approval_id.as_str())
|
||||
.bind(request.operation_id.as_str())
|
||||
.bind(to_db_version(request.operation_version))
|
||||
.bind(expected_fingerprint)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
@@ -285,6 +315,10 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: FinishApprovalRequest<'_>,
|
||||
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||
let response_payload = request
|
||||
.response_payload
|
||||
.as_ref()
|
||||
.map(crank_core::sanitize_invocation_preview);
|
||||
let row = sqlx::query(
|
||||
"update approval_requests
|
||||
set status = $1,
|
||||
@@ -302,6 +336,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -311,7 +347,7 @@ impl PostgresRegistry {
|
||||
decision_note",
|
||||
)
|
||||
.bind(serialize_enum_text(&request.status, "approval_status")?)
|
||||
.bind(request.response_payload.as_ref().map(Json))
|
||||
.bind(response_payload.as_ref().map(Json))
|
||||
.bind(request.decision_note)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(request.agent_id.as_str())
|
||||
@@ -340,7 +376,8 @@ impl PostgresRegistry {
|
||||
and status = 'approved'
|
||||
returning
|
||||
id, workspace_id, agent_id, operation_id, operation_version,
|
||||
status, risk_level, request_payload_json, response_payload_json,
|
||||
status, risk_level, request_id, trace_id, request_payload_json,
|
||||
response_payload_json,
|
||||
created_at, expires_at, decided_at, decided_by_key_id, decision_note",
|
||||
)
|
||||
.bind(started_at)
|
||||
@@ -377,7 +414,8 @@ impl PostgresRegistry {
|
||||
returning
|
||||
approval.id, approval.workspace_id, approval.agent_id,
|
||||
approval.operation_id, approval.operation_version, approval.status,
|
||||
approval.risk_level, approval.request_payload_json,
|
||||
approval.risk_level, approval.request_id, approval.trace_id,
|
||||
approval.request_payload_json,
|
||||
approval.response_payload_json, approval.created_at, approval.expires_at,
|
||||
approval.decided_at, approval.decided_by_key_id, approval.decision_note",
|
||||
)
|
||||
@@ -421,7 +459,8 @@ impl PostgresRegistry {
|
||||
returning
|
||||
approval.id, approval.workspace_id, approval.agent_id,
|
||||
approval.operation_id, approval.operation_version, approval.status,
|
||||
approval.risk_level, approval.request_payload_json,
|
||||
approval.risk_level, approval.request_id, approval.trace_id,
|
||||
approval.request_payload_json,
|
||||
approval.response_payload_json, approval.created_at, approval.expires_at,
|
||||
approval.decided_at, approval.decided_by_key_id, approval.decision_note",
|
||||
)
|
||||
@@ -453,6 +492,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -473,11 +514,53 @@ impl PostgresRegistry {
|
||||
}
|
||||
|
||||
fn approval_request_fingerprint(payload: &Value) -> Result<String, RegistryError> {
|
||||
let canonical = canonical_json(payload);
|
||||
let canonical = canonical_json(&approval_fingerprint_payload(payload));
|
||||
let encoded = serde_json::to_vec(&canonical)?;
|
||||
Ok(format!("{:x}", Sha256::digest(encoded)))
|
||||
}
|
||||
|
||||
fn approval_fingerprint_payload(value: &Value) -> Value {
|
||||
approval_fingerprint_payload_at(value, 0)
|
||||
}
|
||||
|
||||
fn approval_fingerprint_payload_at(value: &Value, depth: usize) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let filtered = object
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
if depth == 0 && is_approval_control_field(key) {
|
||||
None
|
||||
} else {
|
||||
Some((
|
||||
key.clone(),
|
||||
approval_fingerprint_payload_at(value, depth + 1),
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect::<serde_json::Map<String, Value>>();
|
||||
Value::Object(filtered)
|
||||
}
|
||||
Value::Array(items) => Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| approval_fingerprint_payload_at(item, depth + 1))
|
||||
.collect(),
|
||||
),
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_approval_control_field(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"_crank_confirmation_token"
|
||||
| "_crank_approval_id"
|
||||
| "_crank_approval_token"
|
||||
| "_crank_runtime_confirmation_token"
|
||||
)
|
||||
}
|
||||
|
||||
fn canonical_json(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
@@ -505,6 +588,8 @@ fn map_approval_request_row(row: PgRow) -> Result<ApprovalRequestRecord, Registr
|
||||
&row.get::<String, _>("risk_level"),
|
||||
"approval_risk_level",
|
||||
)?,
|
||||
request_id: row.get("request_id"),
|
||||
trace_id: row.get("trace_id"),
|
||||
request_payload: row.get::<Value, _>("request_payload_json"),
|
||||
response_payload: row.get::<Option<Value>, _>("response_payload_json"),
|
||||
created_at: row.get("created_at"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use crate::model::RecoverAdminPasswordRequest;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
const MAX_ADMIN_BOOTSTRAP_ATTEMPTS: i32 = 5;
|
||||
fn map_auth_user_row(row: &PgRow) -> Result<AuthUserRecord, RegistryError> {
|
||||
let status = row.try_get::<String, _>("status")?;
|
||||
Ok(AuthUserRecord {
|
||||
@@ -16,8 +17,199 @@ fn map_auth_user_row(row: &PgRow) -> Result<AuthUserRecord, RegistryError> {
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn create_admin_bootstrap_contract(
|
||||
&self,
|
||||
request: CreateAdminBootstrapContractRequest<'_>,
|
||||
) -> Result<AdminBootstrapContractRecord, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query(
|
||||
"update admin_bootstrap_contracts
|
||||
set status = 'expired'
|
||||
where status = 'active'
|
||||
and expires_at <= now()",
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row = sqlx::query(
|
||||
"insert into admin_bootstrap_contracts (
|
||||
id, token_hash, email, display_name, status, expires_at, created_at
|
||||
) values (
|
||||
$1, $2, $3, $4, 'active', $5::timestamptz, now()
|
||||
)
|
||||
returning id, email, display_name, status, expires_at, created_at, used_at",
|
||||
)
|
||||
.bind(request.id)
|
||||
.bind(request.token_hash)
|
||||
.bind(request.email)
|
||||
.bind(request.display_name)
|
||||
.bind(*request.expires_at)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
sqlx::Error::Database(db_error)
|
||||
if db_error.constraint() == Some("admin_bootstrap_contracts_single_active_idx") =>
|
||||
{
|
||||
RegistryError::AdminBootstrapUnavailable
|
||||
}
|
||||
other => RegistryError::Storage(other),
|
||||
})?;
|
||||
tx.commit().await?;
|
||||
Ok(AdminBootstrapContractRecord {
|
||||
id: row.try_get("id")?,
|
||||
email: row.try_get("email")?,
|
||||
display_name: row.try_get("display_name")?,
|
||||
status: row.try_get("status")?,
|
||||
expires_at: row.try_get("expires_at")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
used_at: row.try_get("used_at")?,
|
||||
})
|
||||
}
|
||||
pub async fn consume_admin_bootstrap_contract(
|
||||
&self,
|
||||
request: ConsumeAdminBootstrapContractRequest<'_>,
|
||||
) -> Result<UserId, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let existing_admins = sqlx::query_scalar::<_, i64>(
|
||||
"select count(*)::bigint
|
||||
from users
|
||||
where coalesce(password_hash, '') <> ''",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if existing_admins > 0 {
|
||||
return Err(RegistryError::AdminBootstrapRejected);
|
||||
}
|
||||
let contract = sqlx::query(
|
||||
"select id, email, display_name
|
||||
from admin_bootstrap_contracts
|
||||
where token_hash = $1
|
||||
and status = 'active'
|
||||
and expires_at > $2::timestamptz
|
||||
and attempts < $3
|
||||
for update",
|
||||
)
|
||||
.bind(request.token_hash)
|
||||
.bind(*request.now)
|
||||
.bind(MAX_ADMIN_BOOTSTRAP_ATTEMPTS)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(contract) = contract else {
|
||||
let _ = sqlx::query(
|
||||
"update admin_bootstrap_contracts
|
||||
set attempts = least(attempts + 1, 100),
|
||||
status = case
|
||||
when attempts + 1 >= $2 then 'revoked'
|
||||
else status
|
||||
end
|
||||
where token_hash = $1
|
||||
and status = 'active'",
|
||||
)
|
||||
.bind(request.token_hash)
|
||||
.bind(MAX_ADMIN_BOOTSTRAP_ATTEMPTS)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
return Err(RegistryError::AdminBootstrapRejected);
|
||||
};
|
||||
let user_id = format!("user_{}", uuid::Uuid::now_v7().simple());
|
||||
let email: String = contract.try_get("email")?;
|
||||
let display_name: String = contract.try_get("display_name")?;
|
||||
let user_row = sqlx::query(
|
||||
"insert into users (
|
||||
id, email, display_name, password_hash, status, created_at
|
||||
) values (
|
||||
$1, $2, $3, $4, 'active', now()
|
||||
)
|
||||
on conflict (email) do update
|
||||
set display_name = excluded.display_name,
|
||||
password_hash = excluded.password_hash,
|
||||
status = 'active'
|
||||
where users.password_hash is null
|
||||
returning id",
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&email)
|
||||
.bind(&display_name)
|
||||
.bind(request.password_hash)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(RegistryError::Storage)?;
|
||||
let Some(user_row) = user_row else {
|
||||
return Err(RegistryError::AdminBootstrapRejected);
|
||||
};
|
||||
let user_id: String = user_row.try_get("id")?;
|
||||
sqlx::query(
|
||||
"insert into memberships (
|
||||
workspace_id, user_id, role, created_at
|
||||
) values (
|
||||
'ws_default', $1, 'owner', now()
|
||||
)
|
||||
on conflict (workspace_id, user_id) do update
|
||||
set role = 'owner'",
|
||||
)
|
||||
.bind(&user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update admin_bootstrap_contracts
|
||||
set status = 'used',
|
||||
used_at = $2::timestamptz,
|
||||
used_by_user_id = $3
|
||||
where id = $1
|
||||
and status = 'active'",
|
||||
)
|
||||
.bind(contract.try_get::<String, _>("id")?)
|
||||
.bind(*request.now)
|
||||
.bind(&user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(UserId::new(user_id))
|
||||
}
|
||||
pub async fn admin_bootstrap_contract_is_consumable(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<bool, RegistryError> {
|
||||
let existing_admins = sqlx::query_scalar::<_, i64>(
|
||||
"select count(*)::bigint
|
||||
from users
|
||||
where coalesce(password_hash, '') <> ''",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
if existing_admins > 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"select exists(
|
||||
select 1
|
||||
from admin_bootstrap_contracts
|
||||
where token_hash = $1
|
||||
and status = 'active'
|
||||
and expires_at > $2::timestamptz
|
||||
and attempts < $3
|
||||
)",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.bind(*now)
|
||||
.bind(MAX_ADMIN_BOOTSTRAP_ATTEMPTS)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(exists)
|
||||
}
|
||||
pub async fn has_password_admin(&self) -> Result<bool, RegistryError> {
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"select exists(
|
||||
select 1 from users
|
||||
where coalesce(password_hash, '') <> ''
|
||||
)",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(exists)
|
||||
}
|
||||
pub async fn ensure_bootstrap_user(
|
||||
&self,
|
||||
email: &str,
|
||||
@@ -52,7 +244,6 @@ impl PostgresRegistry {
|
||||
{
|
||||
return Ok(UserId::new(id));
|
||||
}
|
||||
|
||||
let existing = sqlx::query_scalar::<_, String>(
|
||||
"select id
|
||||
from users
|
||||
@@ -62,10 +253,8 @@ impl PostgresRegistry {
|
||||
.bind(email)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(UserId::new(existing))
|
||||
}
|
||||
|
||||
pub async fn upsert_bootstrap_user(
|
||||
&self,
|
||||
email: &str,
|
||||
@@ -75,7 +264,6 @@ impl PostgresRegistry {
|
||||
self.ensure_bootstrap_user(email, display_name, password_hash)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ensure_membership(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
@@ -99,10 +287,8 @@ impl PostgresRegistry {
|
||||
.bind(serialize_enum_text(&role, "role")?)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_auth_user_by_email(
|
||||
&self,
|
||||
email: &str,
|
||||
@@ -122,10 +308,8 @@ impl PostgresRegistry {
|
||||
.bind(email)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.as_ref().map(map_auth_user_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn get_auth_user_by_id(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -145,10 +329,8 @@ impl PostgresRegistry {
|
||||
.bind(user_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.as_ref().map(map_auth_user_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn update_user_profile(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -173,7 +355,6 @@ impl PostgresRegistry {
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|error| map_user_update_error(error, user_id, email))?;
|
||||
|
||||
build_user(
|
||||
row.id,
|
||||
row.email,
|
||||
@@ -182,7 +363,6 @@ impl PostgresRegistry {
|
||||
row.created_at,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn update_user_password(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -197,16 +377,13 @@ impl PostgresRegistry {
|
||||
.bind(password_hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::UserNotFound {
|
||||
user_id: user_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_user_password_and_revoke_other_sessions(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -223,13 +400,11 @@ impl PostgresRegistry {
|
||||
.bind(password_hash)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::UserNotFound {
|
||||
user_id: user_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked'
|
||||
@@ -241,17 +416,49 @@ impl PostgresRegistry {
|
||||
.bind(current_session_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_user_password_and_revoke_all_sessions(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
password_hash: &str,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"update users
|
||||
set password_hash = $2
|
||||
where id = $1",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.bind(password_hash)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::UserNotFound {
|
||||
user_id: user_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked',
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where user_id = $1
|
||||
and status = 'active'",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn create_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
user_id: &UserId,
|
||||
current_workspace_id: Option<&WorkspaceId>,
|
||||
secret_hash: &str,
|
||||
csrf_hash: Option<&str>,
|
||||
expires_at: &OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
@@ -260,6 +467,7 @@ impl PostgresRegistry {
|
||||
user_id,
|
||||
current_workspace_id,
|
||||
secret_hash,
|
||||
csrf_hash,
|
||||
status,
|
||||
expires_at,
|
||||
last_seen_at,
|
||||
@@ -269,8 +477,9 @@ impl PostgresRegistry {
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
'active',
|
||||
$5::timestamptz,
|
||||
$6::timestamptz,
|
||||
now(),
|
||||
now()
|
||||
)",
|
||||
@@ -279,54 +488,55 @@ impl PostgresRegistry {
|
||||
.bind(user_id.as_str())
|
||||
.bind(current_workspace_id.map(|id| id.as_str()))
|
||||
.bind(secret_hash)
|
||||
.bind(csrf_hash)
|
||||
.bind(*expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
secret_hash: &str,
|
||||
) -> Result<Option<SessionRecord>, RegistryError> {
|
||||
let row = sqlx::query!(
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
s.id,
|
||||
s.user_id,
|
||||
s.current_workspace_id,
|
||||
s.csrf_hash,
|
||||
u.email,
|
||||
u.display_name,
|
||||
u.status,
|
||||
u.created_at as \"created_at!: OffsetDateTime\"
|
||||
u.created_at as created_at
|
||||
from user_sessions s
|
||||
join users u on u.id = s.user_id
|
||||
where s.id = $1
|
||||
and s.secret_hash = $2
|
||||
and s.status = 'active'
|
||||
and u.status = 'active'
|
||||
and s.expires_at > now()
|
||||
limit 1",
|
||||
session_id.as_str(),
|
||||
secret_hash,
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.bind(secret_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let user_id = UserId::new(row.user_id);
|
||||
let user_id = UserId::new(row.try_get::<String, _>("user_id")?);
|
||||
let user = User {
|
||||
id: user_id.clone(),
|
||||
email: row.email,
|
||||
display_name: row.display_name,
|
||||
status: deserialize_enum_text(&row.status, "status")?,
|
||||
created_at: row.created_at,
|
||||
email: row.try_get("email")?,
|
||||
display_name: row.try_get("display_name")?,
|
||||
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
};
|
||||
let memberships = self.list_workspaces_for_user(&user_id).await?;
|
||||
let stored_workspace_id = row.current_workspace_id.map(WorkspaceId::new);
|
||||
let stored_workspace_id = row
|
||||
.try_get::<Option<String>, _>("current_workspace_id")?
|
||||
.map(WorkspaceId::new);
|
||||
let default_workspace_id = memberships
|
||||
.iter()
|
||||
.find(|membership| membership.workspace.id.as_str() == "ws_default")
|
||||
@@ -344,15 +554,56 @@ impl PostgresRegistry {
|
||||
.map(|membership| membership.workspace.id.clone())
|
||||
})
|
||||
});
|
||||
|
||||
Ok(Some(SessionRecord {
|
||||
session_id: UserSessionId::new(row.id),
|
||||
session_id: UserSessionId::new(row.try_get::<String, _>("id")?),
|
||||
user,
|
||||
memberships,
|
||||
current_workspace_id,
|
||||
csrf_hash: row.try_get("csrf_hash")?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn verify_session_csrf(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
csrf_hash: &str,
|
||||
) -> Result<bool, RegistryError> {
|
||||
let allowed = sqlx::query_scalar::<_, bool>(
|
||||
"select exists(
|
||||
select 1
|
||||
from user_sessions
|
||||
where id = $1
|
||||
and csrf_hash = $2
|
||||
and status = 'active'
|
||||
and expires_at > now()
|
||||
)",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.bind(csrf_hash)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(allowed)
|
||||
}
|
||||
pub async fn update_user_session_csrf(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
csrf_hash: &str,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"update user_sessions
|
||||
set csrf_hash = $2
|
||||
where id = $1
|
||||
and status = 'active'
|
||||
and expires_at > now()",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.bind(csrf_hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::AdminCsrfRejected);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn touch_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
@@ -369,26 +620,154 @@ impl PostgresRegistry {
|
||||
.bind(session_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked'
|
||||
set status = 'revoked',
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where id = $1",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke_all_user_sessions(&self, user_id: &UserId) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked',
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where user_id = $1
|
||||
and status = 'active'",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn recover_admin_password(
|
||||
&self,
|
||||
request: RecoverAdminPasswordRequest<'_>,
|
||||
) -> Result<UserId, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let row = sqlx::query(
|
||||
"update users
|
||||
set password_hash = $2,
|
||||
status = 'active'
|
||||
where email = $1
|
||||
and coalesce(password_hash, '') <> ''
|
||||
returning id",
|
||||
)
|
||||
.bind(request.email)
|
||||
.bind(request.password_hash)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(RegistryError::AdminRecoveryRejected);
|
||||
};
|
||||
let user_id = UserId::new(row.try_get::<String, _>("id")?);
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked',
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where user_id = $1
|
||||
and status = 'active'",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"insert into admin_security_audit_events (
|
||||
id, action, outcome, actor_user_id, session_id, request_id, trace_id, source, created_at
|
||||
) values (
|
||||
$1, 'recovery_completed', 'success', $2, null, null, null, 'local_cli', now()
|
||||
)",
|
||||
)
|
||||
.bind(request.audit_id)
|
||||
.bind(user_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(user_id)
|
||||
}
|
||||
pub async fn login_backoff_locked_until(
|
||||
&self,
|
||||
scope_hash: &str,
|
||||
) -> Result<Option<OffsetDateTime>, RegistryError> {
|
||||
let value = sqlx::query_scalar::<_, Option<OffsetDateTime>>(
|
||||
"select locked_until
|
||||
from admin_login_backoff
|
||||
where scope_hash = $1
|
||||
and locked_until > now()",
|
||||
)
|
||||
.bind(scope_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(value)
|
||||
}
|
||||
pub async fn record_login_failure(
|
||||
&self,
|
||||
scope_hash: &str,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<OffsetDateTime, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"insert into admin_login_backoff (
|
||||
scope_hash, failure_count, locked_until, last_attempt_at, updated_at
|
||||
) values (
|
||||
$1, 1, $2::timestamptz + interval '1 second', $2::timestamptz, $2::timestamptz
|
||||
)
|
||||
on conflict (scope_hash) do update
|
||||
set failure_count = least(admin_login_backoff.failure_count + 1, 1000),
|
||||
locked_until = $2::timestamptz + (
|
||||
least(300, power(2, least(admin_login_backoff.failure_count + 1, 8)))::int
|
||||
* interval '1 second'
|
||||
),
|
||||
last_attempt_at = $2::timestamptz,
|
||||
updated_at = $2::timestamptz
|
||||
returning locked_until",
|
||||
)
|
||||
.bind(scope_hash)
|
||||
.bind(*now)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
row.try_get("locked_until").map_err(RegistryError::Storage)
|
||||
}
|
||||
pub async fn reset_login_backoff(&self, scope_hash: &str) -> Result<(), RegistryError> {
|
||||
sqlx::query("delete from admin_login_backoff where scope_hash = $1")
|
||||
.bind(scope_hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn record_admin_security_audit(
|
||||
&self,
|
||||
request: AdminSecurityAuditRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"insert into admin_security_audit_events (
|
||||
id, action, outcome, actor_user_id, session_id, request_id, trace_id, source, created_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, now()
|
||||
)",
|
||||
)
|
||||
.bind(request.id)
|
||||
.bind(request.action)
|
||||
.bind(request.outcome)
|
||||
.bind(request.actor_user_id.map(|id| id.as_str()))
|
||||
.bind(request.session_id.map(|id| id.as_str()))
|
||||
.bind(request.request_id)
|
||||
.bind(request.trace_id)
|
||||
.bind(request.source)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn set_user_session_current_workspace(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
@@ -403,10 +782,8 @@ impl PostgresRegistry {
|
||||
.bind(workspace_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn user_has_workspace_access(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -424,14 +801,39 @@ impl PostgresRegistry {
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.allowed)
|
||||
}
|
||||
|
||||
pub async fn save_auth_profile(
|
||||
&self,
|
||||
request: SaveAuthProfileRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let mut secret_ids = request.profile.config.secret_ids();
|
||||
secret_ids.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||
secret_ids.dedup_by(|left, right| left.as_str() == right.as_str());
|
||||
for secret_id in &secret_ids {
|
||||
super::secret::lock_secret_reference(&mut tx, request.workspace_id, secret_id).await?;
|
||||
let row = sqlx::query(
|
||||
"select status
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(RegistryError::SecretNotFound {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
if row.get::<String, _>("status") != "active" {
|
||||
return Err(RegistryError::SecretInactive {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
sqlx::query(
|
||||
"insert into auth_profiles (
|
||||
id,
|
||||
@@ -456,12 +858,11 @@ impl PostgresRegistry {
|
||||
.bind(Json(serialize_json_value(&request.profile.config)?))
|
||||
.bind(request.profile.created_at)
|
||||
.bind(request.profile.updated_at)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_auth_profile(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
@@ -483,7 +884,6 @@ impl PostgresRegistry {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| {
|
||||
build_auth_profile(
|
||||
row.id,
|
||||
@@ -497,7 +897,6 @@ impl PostgresRegistry {
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn list_auth_profiles(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
@@ -518,7 +917,6 @@ impl PostgresRegistry {
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
build_auth_profile(
|
||||
@@ -533,14 +931,12 @@ impl PostgresRegistry {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn list_auth_profiles_referencing_secret(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<Vec<AuthProfile>, RegistryError> {
|
||||
let profiles = self.list_auth_profiles(workspace_id).await?;
|
||||
|
||||
Ok(profiles
|
||||
.into_iter()
|
||||
.filter(|profile| {
|
||||
|
||||
@@ -0,0 +1,934 @@
|
||||
use super::*;
|
||||
|
||||
const MASTER_KEY_PAGE_LIMIT_MAX: i64 = 10_000;
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn verify_or_register_master_key_identity(
|
||||
&self,
|
||||
candidate: MasterKeyIdentityCandidate<'_>,
|
||||
) -> Result<MasterKeyIdentityRecord, RegistryError> {
|
||||
validate_master_key_candidate(&candidate)?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
lock_master_key_authority(&mut tx).await?;
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
epoch,
|
||||
fingerprint,
|
||||
cipher_contract,
|
||||
status,
|
||||
backup_ref,
|
||||
created_at,
|
||||
activated_at,
|
||||
retired_at
|
||||
from master_key_identities
|
||||
where status = 'active'
|
||||
order by epoch desc
|
||||
limit 1
|
||||
for update",
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let record = if let Some(row) = row {
|
||||
let record = map_master_key_identity(row);
|
||||
if record.epoch != candidate.epoch
|
||||
|| record.fingerprint != candidate.fingerprint
|
||||
|| record.cipher_contract != candidate.cipher_contract
|
||||
{
|
||||
return Err(RegistryError::MasterKeyIdentityMismatch {
|
||||
epoch: record.epoch,
|
||||
});
|
||||
}
|
||||
record
|
||||
} else {
|
||||
sqlx::query(
|
||||
"insert into master_key_identities (
|
||||
epoch,
|
||||
fingerprint,
|
||||
cipher_contract,
|
||||
status,
|
||||
backup_ref,
|
||||
created_at,
|
||||
activated_at,
|
||||
retired_at
|
||||
) values (
|
||||
$1, $2, $3, 'active', null, $4::timestamptz, $4::timestamptz, null
|
||||
)",
|
||||
)
|
||||
.bind(candidate.epoch)
|
||||
.bind(candidate.fingerprint)
|
||||
.bind(candidate.cipher_contract)
|
||||
.bind(candidate.observed_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
MasterKeyIdentityRecord {
|
||||
epoch: candidate.epoch,
|
||||
fingerprint: candidate.fingerprint.to_owned(),
|
||||
cipher_contract: candidate.cipher_contract.to_owned(),
|
||||
status: "active".to_owned(),
|
||||
backup_ref: None,
|
||||
created_at: *candidate.observed_at,
|
||||
activated_at: Some(*candidate.observed_at),
|
||||
retired_at: None,
|
||||
}
|
||||
};
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn master_key_rotation_status(
|
||||
&self,
|
||||
) -> Result<MasterKeyRotationStatus, RegistryError> {
|
||||
let active_identity = self.active_master_key_identity().await?;
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
source_epoch,
|
||||
target_epoch,
|
||||
target_fingerprint,
|
||||
state,
|
||||
backup_ref,
|
||||
checkpoint_secret_id,
|
||||
total_secret_versions,
|
||||
processed_secret_versions,
|
||||
verified_secret_versions,
|
||||
failure_code,
|
||||
created_at,
|
||||
updated_at
|
||||
from master_key_rotations
|
||||
order by created_at desc, id asc",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let rotations = rows.into_iter().map(map_master_key_rotation).collect();
|
||||
Ok(MasterKeyRotationStatus {
|
||||
active_identity,
|
||||
rotations,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn active_master_key_identity(
|
||||
&self,
|
||||
) -> Result<Option<MasterKeyIdentityRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
epoch,
|
||||
fingerprint,
|
||||
cipher_contract,
|
||||
status,
|
||||
backup_ref,
|
||||
created_at,
|
||||
activated_at,
|
||||
retired_at
|
||||
from master_key_identities
|
||||
where status = 'active'
|
||||
order by epoch desc
|
||||
limit 1",
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(map_master_key_identity))
|
||||
}
|
||||
|
||||
pub async fn master_key_fingerprint_exists(
|
||||
&self,
|
||||
fingerprint: &str,
|
||||
) -> Result<bool, RegistryError> {
|
||||
let valid_fingerprint = fingerprint.len() == 64
|
||||
&& fingerprint
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
|
||||
if !valid_fingerprint {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let count = sqlx::query_scalar::<_, i64>(
|
||||
"select count(*) from master_key_identities where fingerprint = $1",
|
||||
)
|
||||
.bind(fingerprint)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub async fn list_secret_versions_for_master_key_epoch(
|
||||
&self,
|
||||
epoch: i64,
|
||||
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
|
||||
if epoch < 1 {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
target_ciphertext,
|
||||
target_key_version,
|
||||
target_master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
from secret_versions
|
||||
where master_key_epoch = $1
|
||||
order by secret_id asc, version asc",
|
||||
)
|
||||
.bind(epoch)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(map_secret_version_record).collect()
|
||||
}
|
||||
|
||||
pub async fn list_secret_versions_for_master_key_epoch_page(
|
||||
&self,
|
||||
epoch: i64,
|
||||
after_secret_id: Option<&str>,
|
||||
after_version: Option<u32>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
|
||||
if epoch < 1 || !(1..=MASTER_KEY_PAGE_LIMIT_MAX).contains(&limit) {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let after_db_version = after_version.map(to_db_version);
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
target_ciphertext,
|
||||
target_key_version,
|
||||
target_master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
from secret_versions
|
||||
where master_key_epoch = $1
|
||||
and (
|
||||
$2::text is null
|
||||
or (secret_id, version) > ($2::text, $3::integer)
|
||||
)
|
||||
order by secret_id asc, version asc
|
||||
limit $4",
|
||||
)
|
||||
.bind(epoch)
|
||||
.bind(after_secret_id)
|
||||
.bind(after_db_version)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(map_secret_version_record).collect()
|
||||
}
|
||||
|
||||
pub async fn list_target_secret_versions_for_master_key_rotation(
|
||||
&self,
|
||||
target_epoch: i64,
|
||||
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
|
||||
if target_epoch < 1 {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
target_ciphertext,
|
||||
target_key_version,
|
||||
target_master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
from secret_versions
|
||||
where target_master_key_epoch = $1
|
||||
order by secret_id asc, version asc",
|
||||
)
|
||||
.bind(target_epoch)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(map_secret_version_record).collect()
|
||||
}
|
||||
|
||||
pub async fn list_target_secret_versions_for_master_key_rotation_page(
|
||||
&self,
|
||||
target_epoch: i64,
|
||||
after_secret_id: Option<&str>,
|
||||
after_version: Option<u32>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
|
||||
if target_epoch < 1 || !(1..=MASTER_KEY_PAGE_LIMIT_MAX).contains(&limit) {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let after_db_version = after_version.map(to_db_version);
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
target_ciphertext,
|
||||
target_key_version,
|
||||
target_master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
from secret_versions
|
||||
where target_master_key_epoch = $1
|
||||
and (
|
||||
$2::text is null
|
||||
or (secret_id, version) > ($2::text, $3::integer)
|
||||
)
|
||||
order by secret_id asc, version asc
|
||||
limit $4",
|
||||
)
|
||||
.bind(target_epoch)
|
||||
.bind(after_secret_id)
|
||||
.bind(after_db_version)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(map_secret_version_record).collect()
|
||||
}
|
||||
|
||||
pub async fn begin_master_key_rotation(
|
||||
&self,
|
||||
source_epoch: i64,
|
||||
target_epoch: i64,
|
||||
target_fingerprint: &str,
|
||||
backup_ref: Option<&str>,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
validate_rotation_request(source_epoch, target_epoch, target_fingerprint, backup_ref)?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
lock_master_key_authority(&mut tx).await?;
|
||||
let active = select_active_master_key_identity_for_update(&mut tx).await?;
|
||||
let Some(active) = active else {
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
};
|
||||
if active.epoch != source_epoch || active.fingerprint == target_fingerprint {
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
|
||||
let existing_active = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
source_epoch,
|
||||
target_epoch,
|
||||
target_fingerprint,
|
||||
state,
|
||||
backup_ref,
|
||||
checkpoint_secret_id,
|
||||
total_secret_versions,
|
||||
processed_secret_versions,
|
||||
verified_secret_versions,
|
||||
failure_code,
|
||||
created_at,
|
||||
updated_at
|
||||
from master_key_rotations
|
||||
where state in ('running', 'verifying', 'verified')
|
||||
order by created_at asc
|
||||
limit 1
|
||||
for update",
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if let Some(row) = existing_active {
|
||||
let record = map_master_key_rotation(row);
|
||||
if record.source_epoch == source_epoch
|
||||
&& record.target_epoch == target_epoch
|
||||
&& record.target_fingerprint == target_fingerprint
|
||||
{
|
||||
tx.commit().await?;
|
||||
return Ok(record);
|
||||
}
|
||||
return Err(RegistryError::MasterKeyRotationInProgress);
|
||||
}
|
||||
|
||||
if sqlx::query_scalar::<_, i64>(
|
||||
"select count(*) from master_key_identities where fingerprint = $1",
|
||||
)
|
||||
.bind(target_fingerprint)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
> 0
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
|
||||
let total = sqlx::query_scalar::<_, i64>(
|
||||
"select count(*) from secret_versions where master_key_epoch = $1",
|
||||
)
|
||||
.bind(source_epoch)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let rotation_id = format!("master-key-e{source_epoch}-to-e{target_epoch}");
|
||||
let aborted = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
source_epoch,
|
||||
target_epoch,
|
||||
target_fingerprint,
|
||||
state,
|
||||
backup_ref,
|
||||
checkpoint_secret_id,
|
||||
total_secret_versions,
|
||||
processed_secret_versions,
|
||||
verified_secret_versions,
|
||||
failure_code,
|
||||
created_at,
|
||||
updated_at
|
||||
from master_key_rotations
|
||||
where id = $1 and state in ('aborted', 'failed')
|
||||
for update",
|
||||
)
|
||||
.bind(&rotation_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if let Some(row) = aborted {
|
||||
let record = map_master_key_rotation(row);
|
||||
if record.source_epoch != source_epoch
|
||||
|| record.target_epoch != target_epoch
|
||||
|| record.target_fingerprint != target_fingerprint
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
sqlx::query(
|
||||
"update secret_versions
|
||||
set target_ciphertext = null,
|
||||
target_key_version = null,
|
||||
target_master_key_epoch = null
|
||||
where target_master_key_epoch = $1",
|
||||
)
|
||||
.bind(target_epoch)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set state = 'running',
|
||||
backup_ref = $2,
|
||||
checkpoint_secret_id = null,
|
||||
total_secret_versions = $3,
|
||||
processed_secret_versions = 0,
|
||||
verified_secret_versions = 0,
|
||||
failure_code = null,
|
||||
updated_at = $4::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(&rotation_id)
|
||||
.bind(backup_ref)
|
||||
.bind(total)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let record = select_master_key_rotation_for_update(&mut tx, &rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
return Ok(record);
|
||||
}
|
||||
sqlx::query(
|
||||
"insert into master_key_rotations (
|
||||
id,
|
||||
source_epoch,
|
||||
target_epoch,
|
||||
target_fingerprint,
|
||||
state,
|
||||
backup_ref,
|
||||
checkpoint_secret_id,
|
||||
total_secret_versions,
|
||||
processed_secret_versions,
|
||||
verified_secret_versions,
|
||||
failure_code,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
$1, $2, $3, $4, 'running', $5, null, $6, 0, 0, null, $7::timestamptz, $7::timestamptz
|
||||
)",
|
||||
)
|
||||
.bind(&rotation_id)
|
||||
.bind(source_epoch)
|
||||
.bind(target_epoch)
|
||||
.bind(target_fingerprint)
|
||||
.bind(backup_ref)
|
||||
.bind(total)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let record = select_master_key_rotation_for_update(&mut tx, &rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn stage_master_key_rotation_ciphertext(
|
||||
&self,
|
||||
rotation_id: &str,
|
||||
secret_id: &SecretId,
|
||||
version: u32,
|
||||
source_epoch: i64,
|
||||
target_ciphertext: &str,
|
||||
target_key_version: &str,
|
||||
target_epoch: i64,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
if rotation.state != "running"
|
||||
|| rotation.source_epoch != source_epoch
|
||||
|| rotation.target_epoch != target_epoch
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
let result = sqlx::query(
|
||||
"update secret_versions
|
||||
set target_ciphertext = $4,
|
||||
target_key_version = $5,
|
||||
target_master_key_epoch = $6
|
||||
where secret_id = $1
|
||||
and version = $2
|
||||
and master_key_epoch = $3
|
||||
and (
|
||||
target_master_key_epoch is null
|
||||
or target_master_key_epoch = $6
|
||||
)
|
||||
and (
|
||||
target_ciphertext is null
|
||||
or target_ciphertext = $4
|
||||
)",
|
||||
)
|
||||
.bind(secret_id.as_str())
|
||||
.bind(to_db_version(version))
|
||||
.bind(source_epoch)
|
||||
.bind(target_ciphertext)
|
||||
.bind(target_key_version)
|
||||
.bind(target_epoch)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
let processed = count_staged_rotation_targets(&mut tx, source_epoch, target_epoch).await?;
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set checkpoint_secret_id = $2,
|
||||
processed_secret_versions = $3,
|
||||
updated_at = $4::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.bind(secret_id.as_str())
|
||||
.bind(processed)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn finish_master_key_rotation_batches(
|
||||
&self,
|
||||
rotation_id: &str,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
if rotation.state != "running"
|
||||
|| rotation.processed_secret_versions != rotation.total_secret_versions
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set state = 'verifying',
|
||||
updated_at = $2::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn verify_master_key_rotation(
|
||||
&self,
|
||||
rotation_id: &str,
|
||||
verified_secret_versions: i64,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
if rotation.state != "verifying"
|
||||
|| verified_secret_versions != rotation.total_secret_versions
|
||||
|| rotation.processed_secret_versions != rotation.total_secret_versions
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationVerificationFailed);
|
||||
}
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set state = 'verified',
|
||||
verified_secret_versions = $2,
|
||||
updated_at = $3::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.bind(verified_secret_versions)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn promote_master_key_rotation(
|
||||
&self,
|
||||
rotation_id: &str,
|
||||
target_identity: MasterKeyIdentityCandidate<'_>,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
validate_master_key_candidate(&target_identity)?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
if rotation.state != "verified"
|
||||
|| rotation.target_epoch != target_identity.epoch
|
||||
|| rotation.target_fingerprint != target_identity.fingerprint
|
||||
|| rotation.verified_secret_versions != rotation.total_secret_versions
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
let processed =
|
||||
count_staged_rotation_targets(&mut tx, rotation.source_epoch, rotation.target_epoch)
|
||||
.await?;
|
||||
if processed != rotation.total_secret_versions {
|
||||
return Err(RegistryError::MasterKeyRotationVerificationFailed);
|
||||
}
|
||||
sqlx::query(
|
||||
"update master_key_identities
|
||||
set status = 'retired',
|
||||
retired_at = $2::timestamptz
|
||||
where status = 'active' and epoch = $1",
|
||||
)
|
||||
.bind(rotation.source_epoch)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.and_then(|result| {
|
||||
if result.rows_affected() == 1 {
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(sqlx::Error::RowNotFound)
|
||||
}
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
sqlx::Error::RowNotFound => RegistryError::MasterKeyRotationConflict,
|
||||
other => RegistryError::Storage(other),
|
||||
})?;
|
||||
sqlx::query(
|
||||
"insert into master_key_identities (
|
||||
epoch,
|
||||
fingerprint,
|
||||
cipher_contract,
|
||||
status,
|
||||
backup_ref,
|
||||
created_at,
|
||||
activated_at,
|
||||
retired_at
|
||||
) values (
|
||||
$1, $2, $3, 'active', $4, $5::timestamptz, $5::timestamptz, null
|
||||
)",
|
||||
)
|
||||
.bind(target_identity.epoch)
|
||||
.bind(target_identity.fingerprint)
|
||||
.bind(target_identity.cipher_contract)
|
||||
.bind(rotation.backup_ref.as_deref())
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update secret_versions
|
||||
set ciphertext = target_ciphertext,
|
||||
key_version = target_key_version,
|
||||
master_key_epoch = target_master_key_epoch,
|
||||
target_ciphertext = null,
|
||||
target_key_version = null,
|
||||
target_master_key_epoch = null
|
||||
where master_key_epoch = $1
|
||||
and target_master_key_epoch = $2",
|
||||
)
|
||||
.bind(rotation.source_epoch)
|
||||
.bind(rotation.target_epoch)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set state = 'promoted',
|
||||
updated_at = $2::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn abort_master_key_rotation(
|
||||
&self,
|
||||
rotation_id: &str,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
if rotation.state == "promoted" {
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
sqlx::query(
|
||||
"update secret_versions
|
||||
set target_ciphertext = null,
|
||||
target_key_version = null,
|
||||
target_master_key_epoch = null
|
||||
where target_master_key_epoch = $1",
|
||||
)
|
||||
.bind(rotation.target_epoch)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set state = 'aborted',
|
||||
updated_at = $2::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_master_key_candidate(
|
||||
candidate: &MasterKeyIdentityCandidate<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let valid_fingerprint = candidate.fingerprint.len() == 64
|
||||
&& candidate
|
||||
.fingerprint
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
|
||||
if candidate.epoch < 1
|
||||
|| !valid_fingerprint
|
||||
|| candidate.cipher_contract != crate::model::MASTER_KEY_CIPHER_CONTRACT
|
||||
{
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_master_key_identity(row: sqlx::postgres::PgRow) -> MasterKeyIdentityRecord {
|
||||
MasterKeyIdentityRecord {
|
||||
epoch: row.get::<i64, _>("epoch"),
|
||||
fingerprint: row.get::<String, _>("fingerprint"),
|
||||
cipher_contract: row.get::<String, _>("cipher_contract"),
|
||||
status: row.get::<String, _>("status"),
|
||||
backup_ref: row.get::<Option<String>, _>("backup_ref"),
|
||||
created_at: row.get("created_at"),
|
||||
activated_at: row.get("activated_at"),
|
||||
retired_at: row.get("retired_at"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_master_key_rotation(row: sqlx::postgres::PgRow) -> MasterKeyRotationRecord {
|
||||
MasterKeyRotationRecord {
|
||||
id: row.get::<String, _>("id"),
|
||||
source_epoch: row.get::<i64, _>("source_epoch"),
|
||||
target_epoch: row.get::<i64, _>("target_epoch"),
|
||||
target_fingerprint: row.get::<String, _>("target_fingerprint"),
|
||||
state: row.get::<String, _>("state"),
|
||||
backup_ref: row.get::<Option<String>, _>("backup_ref"),
|
||||
checkpoint_secret_id: row.get::<Option<String>, _>("checkpoint_secret_id"),
|
||||
total_secret_versions: row.get::<i64, _>("total_secret_versions"),
|
||||
processed_secret_versions: row.get::<i64, _>("processed_secret_versions"),
|
||||
verified_secret_versions: row.get::<i64, _>("verified_secret_versions"),
|
||||
failure_code: row.get::<Option<String>, _>("failure_code"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_secret_version_record(
|
||||
row: sqlx::postgres::PgRow,
|
||||
) -> Result<SecretVersionRecord, RegistryError> {
|
||||
Ok(SecretVersionRecord {
|
||||
secret_version: SecretVersion {
|
||||
secret_id: SecretId::new(row.get::<String, _>("secret_id")),
|
||||
version: from_db_version(row.get::<i32, _>("version"), "version")?,
|
||||
ciphertext: row.get::<String, _>("ciphertext"),
|
||||
key_version: row.get::<String, _>("key_version"),
|
||||
created_at: row.get("created_at"),
|
||||
created_by: row.get::<Option<String>, _>("created_by").map(UserId::new),
|
||||
},
|
||||
master_key_epoch: row.get::<i64, _>("master_key_epoch"),
|
||||
target_ciphertext: row.get::<Option<String>, _>("target_ciphertext"),
|
||||
target_key_version: row.get::<Option<String>, _>("target_key_version"),
|
||||
target_master_key_epoch: row.get::<Option<i64>, _>("target_master_key_epoch"),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_rotation_request(
|
||||
source_epoch: i64,
|
||||
target_epoch: i64,
|
||||
target_fingerprint: &str,
|
||||
backup_ref: Option<&str>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let candidate_observed_at = OffsetDateTime::UNIX_EPOCH;
|
||||
validate_master_key_candidate(&MasterKeyIdentityCandidate {
|
||||
epoch: target_epoch,
|
||||
fingerprint: target_fingerprint,
|
||||
cipher_contract: crate::model::MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &candidate_observed_at,
|
||||
})?;
|
||||
if source_epoch < 1 || target_epoch <= source_epoch {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
if let Some(value) = backup_ref {
|
||||
let invalid = value.is_empty()
|
||||
|| value.len() > 256
|
||||
|| value.bytes().any(|byte| byte.is_ascii_control())
|
||||
|| value.contains("://");
|
||||
if invalid {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_active_master_key_epoch(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
master_key_epoch: i64,
|
||||
) -> Result<(), RegistryError> {
|
||||
if master_key_epoch < 1 {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let active = select_active_master_key_identity_for_update(transaction).await?;
|
||||
match active {
|
||||
Some(identity) if identity.epoch == master_key_epoch => Ok(()),
|
||||
Some(identity) => Err(RegistryError::MasterKeyIdentityMismatch {
|
||||
epoch: identity.epoch,
|
||||
}),
|
||||
None => Err(RegistryError::InvalidMasterKeyIdentity),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_no_active_master_key_rotation(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), RegistryError> {
|
||||
lock_master_key_authority(transaction).await?;
|
||||
let row = sqlx::query(
|
||||
"select id
|
||||
from master_key_rotations
|
||||
where state in ('running', 'verifying', 'verified')
|
||||
order by created_at asc
|
||||
limit 1
|
||||
for update",
|
||||
)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?;
|
||||
if row.is_some() {
|
||||
return Err(RegistryError::MasterKeyRotationInProgress);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn lock_master_key_authority(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"lock table
|
||||
master_key_identities,
|
||||
master_key_rotations
|
||||
in share row exclusive mode",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn select_active_master_key_identity_for_update(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<Option<MasterKeyIdentityRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
epoch,
|
||||
fingerprint,
|
||||
cipher_contract,
|
||||
status,
|
||||
backup_ref,
|
||||
created_at,
|
||||
activated_at,
|
||||
retired_at
|
||||
from master_key_identities
|
||||
where status = 'active'
|
||||
order by epoch desc
|
||||
limit 1
|
||||
for update",
|
||||
)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?;
|
||||
Ok(row.map(map_master_key_identity))
|
||||
}
|
||||
|
||||
async fn select_master_key_rotation_for_update(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
rotation_id: &str,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
source_epoch,
|
||||
target_epoch,
|
||||
target_fingerprint,
|
||||
state,
|
||||
backup_ref,
|
||||
checkpoint_secret_id,
|
||||
total_secret_versions,
|
||||
processed_secret_versions,
|
||||
verified_secret_versions,
|
||||
failure_code,
|
||||
created_at,
|
||||
updated_at
|
||||
from master_key_rotations
|
||||
where id = $1
|
||||
for update",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?;
|
||||
row.map(map_master_key_rotation)
|
||||
.ok_or_else(|| RegistryError::MasterKeyRotationNotFound {
|
||||
rotation_id: rotation_id.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_staged_rotation_targets(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
source_epoch: i64,
|
||||
target_epoch: i64,
|
||||
) -> Result<i64, RegistryError> {
|
||||
let count = sqlx::query_scalar::<_, i64>(
|
||||
"select count(*)
|
||||
from secret_versions
|
||||
where master_key_epoch = $1
|
||||
and target_master_key_epoch = $2",
|
||||
)
|
||||
.bind(source_epoch)
|
||||
.bind(target_epoch)
|
||||
.fetch_one(&mut **transaction)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
mod agent;
|
||||
mod agent_catalog;
|
||||
mod api_key;
|
||||
mod approval;
|
||||
mod auth;
|
||||
mod connection;
|
||||
mod import_job;
|
||||
mod master_key;
|
||||
mod observability;
|
||||
mod onboarding;
|
||||
mod operation;
|
||||
mod operation_artifact;
|
||||
mod operation_published;
|
||||
mod pool_config;
|
||||
mod product_event;
|
||||
mod secret;
|
||||
mod upstream;
|
||||
mod workspace;
|
||||
@@ -17,8 +22,8 @@ use crank_core::{
|
||||
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,
|
||||
PlatformApiKeyStatus, ProductEventId, Secret, SecretId, SecretVersion, Target, UsageRollup,
|
||||
User, UserId, UserSessionId, Workspace, WorkspaceId,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde_json::Value;
|
||||
@@ -30,25 +35,34 @@ pub use pool_config::{PostgresPoolConfig, PostgresPoolConfigError};
|
||||
use crate::{
|
||||
error::RegistryError,
|
||||
model::{
|
||||
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApplyImportJobRequest,
|
||||
ApprovalRequestRecord, AuthUserRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
||||
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DecideApprovalRequest, DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest,
|
||||
FinishImportJobRequest, ImportConflictMode, ImportJob, ImportJobApplyResult, ImportJobId,
|
||||
ImportJobStatus, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
|
||||
AdminBootstrapContractRecord, AdminSecurityAuditRequest, AgentSummary, AgentVersionRecord,
|
||||
AppendProductEventOutcome, AppendProductEventRequest, AppliedImportOperation,
|
||||
ApplyImportJobRequest, ApprovalRequestRecord, AuthUserRecord,
|
||||
ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
|
||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||
DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
|
||||
ImportConflictMode, ImportJob, ImportJobApplyResult, ImportJobId, ImportJobStatus,
|
||||
InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome,
|
||||
InvocationRetentionPolicy, InvocationRetentionStatus, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, ListProductEventsQuery, MasterKeyIdentityCandidate,
|
||||
MasterKeyIdentityRecord, MasterKeyRotationRecord, MasterKeyRotationStatus,
|
||||
MembershipRecord, OnboardingMilestoneResult, OnboardingPresentationMilestone,
|
||||
OperationAgentRef, OperationSampleMetadata, OperationStateExpectation, OperationSummary,
|
||||
OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, ProductEventRecord,
|
||||
PublishAgentRequest, PublishRequest, PublishedAgentCatalog, PublishedAgentTool,
|
||||
RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest,
|
||||
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
||||
SessionRecord, SkippedImportOperation, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, YamlImportJob,
|
||||
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
RecordOnboardingCompletionRequest, RecordOnboardingMilestoneRequest, RegistryOperation,
|
||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||
SkippedImportOperation, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageOperationBreakdown, UsageOutcomeBreakdown, UsageQuery, UsageRollupRecord,
|
||||
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
|
||||
WorkspaceUpstream, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||
YamlImportJobStatus,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -106,12 +120,23 @@ async fn insert_version_row(
|
||||
generated_draft_json,
|
||||
config_export_json,
|
||||
wizard_state_json,
|
||||
name,
|
||||
display_name,
|
||||
category,
|
||||
protocol,
|
||||
security_level,
|
||||
snapshot_provenance,
|
||||
snapshot_observed_at,
|
||||
published_at,
|
||||
published_by,
|
||||
change_note,
|
||||
created_at,
|
||||
created_by
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||
$9, $10, $11, $12, $13, $14, $15, $16::timestamptz, $17
|
||||
$9, $10, $11, $12, $13, $14,
|
||||
$15, $16, $17, $18, $19, 'native_v4', $20::timestamptz,
|
||||
$21::timestamptz, $22, $23, $24::timestamptz, $25
|
||||
)",
|
||||
)
|
||||
.bind(snapshot.id.as_str())
|
||||
@@ -128,6 +153,17 @@ async fn insert_version_row(
|
||||
.bind(serialize_option_json_value(&snapshot.generated_draft)?.map(Json))
|
||||
.bind(serialize_option_json_value(&snapshot.config_export)?.map(Json))
|
||||
.bind(serialize_option_json_value(&snapshot.wizard_state)?.map(Json))
|
||||
.bind(&snapshot.name)
|
||||
.bind(&snapshot.display_name)
|
||||
.bind(&snapshot.category)
|
||||
.bind(serialize_enum_text(&snapshot.protocol, "protocol")?)
|
||||
.bind(serialize_enum_text(
|
||||
&snapshot.security_level,
|
||||
"security_level",
|
||||
)?)
|
||||
.bind(snapshot.updated_at)
|
||||
.bind(snapshot.published_at)
|
||||
.bind(Option::<&str>::None)
|
||||
.bind(change_note)
|
||||
.bind(snapshot.updated_at)
|
||||
.bind(created_by)
|
||||
@@ -222,10 +258,39 @@ async fn insert_agent_version_row(
|
||||
|
||||
async fn replace_agent_bindings_rows(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
version: u32,
|
||||
bindings: &[AgentOperationBinding],
|
||||
) -> Result<(), RegistryError> {
|
||||
let operation_ids = bindings
|
||||
.iter()
|
||||
.map(|binding| binding.operation_id.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
for operation_id in operation_ids {
|
||||
let status = sqlx::query_scalar::<_, String>(
|
||||
"select status from operations
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(operation_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
match status.as_deref() {
|
||||
Some("archived") => {
|
||||
return Err(RegistryError::OperationArchived {
|
||||
operation_id: operation_id.to_owned(),
|
||||
});
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
return Err(RegistryError::OperationNotFound {
|
||||
operation_id: operation_id.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlx::query(
|
||||
"delete from agent_operation_bindings
|
||||
where agent_id = $1 and agent_version = $2",
|
||||
@@ -263,27 +328,6 @@ async fn replace_agent_bindings_rows(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_immutable_fields(
|
||||
summary: &OperationSummary,
|
||||
snapshot: &RegistryOperation,
|
||||
) -> Result<(), RegistryError> {
|
||||
if summary.name != snapshot.name {
|
||||
return Err(RegistryError::ImmutableOperationFieldChanged {
|
||||
operation_id: snapshot.id.as_str().to_owned(),
|
||||
field: "name",
|
||||
});
|
||||
}
|
||||
|
||||
if summary.protocol != snapshot.protocol {
|
||||
return Err(RegistryError::ImmutableOperationFieldChanged {
|
||||
operation_id: snapshot.id.as_str().to_owned(),
|
||||
field: "protocol",
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_user(
|
||||
id: String,
|
||||
email: String,
|
||||
@@ -324,7 +368,19 @@ fn map_invocation_log_record(row: &PgRow) -> Result<InvocationLogRecord, Registr
|
||||
agent_id: row
|
||||
.try_get::<Option<String>, _>("agent_id")?
|
||||
.map(AgentId::new),
|
||||
platform_api_key_id: row
|
||||
.try_get::<Option<String>, _>("platform_api_key_id")?
|
||||
.map(PlatformApiKeyId::new),
|
||||
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
|
||||
operation_version: row
|
||||
.try_get::<Option<i32>, _>("operation_version")?
|
||||
.map(|value| {
|
||||
u32::try_from(value).map_err(|_| RegistryError::InvalidNumericValue {
|
||||
field: "operation_version",
|
||||
value: i64::from(value),
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
source: deserialize_enum_text(&row.try_get::<String, _>("source")?, "source")?,
|
||||
level: deserialize_enum_text(&row.try_get::<String, _>("level")?, "level")?,
|
||||
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
||||
@@ -345,6 +401,22 @@ fn map_invocation_log_record(row: &PgRow) -> Result<InvocationLogRecord, Registr
|
||||
},
|
||||
duration_ms: to_u64(row.try_get::<i64, _>("duration_ms")?, "duration_ms")?,
|
||||
error_kind: row.try_get("error_kind")?,
|
||||
execution_stage: row
|
||||
.try_get::<Option<String>, _>("execution_stage")?
|
||||
.map(|value| deserialize_enum_text(&value, "execution_stage"))
|
||||
.transpose()?,
|
||||
execution_error_code: row
|
||||
.try_get::<Option<String>, _>("execution_error_code")?
|
||||
.map(|value| deserialize_enum_text(&value, "execution_error_code"))
|
||||
.transpose()?,
|
||||
retryability: row
|
||||
.try_get::<Option<String>, _>("retryability")?
|
||||
.map(|value| deserialize_enum_text(&value, "retryability"))
|
||||
.transpose()?,
|
||||
outcome_certainty: row
|
||||
.try_get::<Option<String>, _>("outcome_certainty")?
|
||||
.map(|value| deserialize_enum_text(&value, "outcome_certainty"))
|
||||
.transpose()?,
|
||||
request_preview: row.try_get::<Json<Value>, _>("request_preview_json")?.0,
|
||||
response_preview: row.try_get::<Json<Value>, _>("response_preview_json")?.0,
|
||||
created_at: row.try_get("created_at")?,
|
||||
@@ -423,6 +495,7 @@ fn build_agent_summary(
|
||||
status: String,
|
||||
current_draft_version: i32,
|
||||
latest_published_version: Option<i32>,
|
||||
catalog_revision: i64,
|
||||
created_at: OffsetDateTime,
|
||||
updated_at: OffsetDateTime,
|
||||
published_at: Option<OffsetDateTime>,
|
||||
@@ -438,6 +511,7 @@ fn build_agent_summary(
|
||||
latest_published_version: latest_published_version
|
||||
.map(|value| from_db_version(value, "latest_published_version"))
|
||||
.transpose()?,
|
||||
catalog_revision,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at,
|
||||
@@ -457,6 +531,7 @@ fn build_operation_summary(
|
||||
status: String,
|
||||
current_draft_version: i32,
|
||||
latest_published_version: Option<i32>,
|
||||
can_delete: bool,
|
||||
created_at: OffsetDateTime,
|
||||
updated_at: OffsetDateTime,
|
||||
published_at: Option<OffsetDateTime>,
|
||||
@@ -479,6 +554,7 @@ fn build_operation_summary(
|
||||
latest_published_version: latest_published_version
|
||||
.map(|value| from_db_version(value, "latest_published_version"))
|
||||
.transpose()?,
|
||||
can_delete,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at,
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
use super::*;
|
||||
|
||||
const PRESERVED_USAGE_WINDOW_DAYS: u16 = 90;
|
||||
|
||||
fn usage_outcome_group_case_sql(prefix: &str) -> String {
|
||||
debug_assert!(matches!(prefix, "" | "l."));
|
||||
format!(
|
||||
"case
|
||||
when {prefix}status = 'ok' then 'success'
|
||||
when {prefix}execution_error_code in (
|
||||
'upstream_auth_error',
|
||||
'upstream_not_found',
|
||||
'upstream_rate_limited',
|
||||
'upstream_server_error',
|
||||
'upstream_status_error',
|
||||
'upstream_timeout',
|
||||
'upstream_transport_error',
|
||||
'upstream_request_too_large',
|
||||
'upstream_response_too_large'
|
||||
) then 'upstream'
|
||||
when execution_error_code in (
|
||||
'authorization_denied',
|
||||
'auth_profile_not_found',
|
||||
'secret_not_found',
|
||||
'secret_invalid',
|
||||
'input_schema_invalid',
|
||||
'input_mapping_invalid',
|
||||
'outbound_target_rejected',
|
||||
'adapter_configuration_invalid',
|
||||
'confirmation_required',
|
||||
'confirmation_invalid',
|
||||
'idempotency_in_progress',
|
||||
'idempotency_conflict',
|
||||
'idempotency_outcome_unknown'
|
||||
) then 'client'
|
||||
when execution_error_code in (
|
||||
'prepared_request_invalid',
|
||||
'output_mapping_invalid',
|
||||
'output_schema_invalid'
|
||||
) then 'schema'
|
||||
when execution_error_code in (
|
||||
'execution_overloaded',
|
||||
'safety_store_unavailable',
|
||||
'protocol_unsupported',
|
||||
'execution_mode_unsupported',
|
||||
'persistence_unavailable',
|
||||
'runtime_internal'
|
||||
) then 'crank'
|
||||
else 'crank'
|
||||
end"
|
||||
)
|
||||
}
|
||||
|
||||
fn invocation_history_loss_category(error: &RegistryError) -> InvocationHistoryLossCategory {
|
||||
match error {
|
||||
RegistryError::Storage(error)
|
||||
@@ -19,12 +70,36 @@ impl PostgresRegistry {
|
||||
pub async fn delete_invocation_logs_before(
|
||||
&self,
|
||||
cutoff: OffsetDateTime,
|
||||
) -> Result<u64, RegistryError> {
|
||||
let result = sqlx::query("delete from invocation_logs where created_at < $1::timestamptz")
|
||||
.bind(cutoff)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
) -> Result<InvocationRetentionOutcome, RegistryError> {
|
||||
let usage_floor = OffsetDateTime::now_utc()
|
||||
- time::Duration::days(i64::from(PRESERVED_USAGE_WINDOW_DAYS));
|
||||
let effective_cutoff = cutoff.min(usage_floor);
|
||||
let result = sqlx::query(
|
||||
"delete from invocation_logs l
|
||||
where l.created_at < $1::timestamptz
|
||||
and not exists (
|
||||
select 1 from onboarding_selections s
|
||||
where s.invocation_log_id = l.id or s.test_log_id = l.id
|
||||
)",
|
||||
)
|
||||
.bind(effective_cutoff)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let deleted_records = result.rows_affected();
|
||||
Ok(InvocationRetentionOutcome {
|
||||
status: if deleted_records == 0 {
|
||||
InvocationRetentionStatus::Noop
|
||||
} else {
|
||||
InvocationRetentionStatus::Completed
|
||||
},
|
||||
deleted_records,
|
||||
policy: InvocationRetentionPolicy {
|
||||
requested_cutoff: cutoff,
|
||||
effective_cutoff,
|
||||
usage_preservation_floor: usage_floor,
|
||||
preserved_usage_window_days: PRESERVED_USAGE_WINDOW_DAYS,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_invocation_log(
|
||||
@@ -63,15 +138,77 @@ impl PostgresRegistry {
|
||||
.ok_or(RegistryError::InvalidCorrelationIdentity { field: "trace_id" })?;
|
||||
crank_core::TraceId::parse(trace_id)
|
||||
.map_err(|_| RegistryError::InvalidCorrelationIdentity { field: "trace_id" })?;
|
||||
let operation_version =
|
||||
request
|
||||
.log
|
||||
.operation_version
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "operation_version",
|
||||
})?;
|
||||
let _stage = request
|
||||
.log
|
||||
.execution_stage
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "execution_stage",
|
||||
})?;
|
||||
let _retryability =
|
||||
request
|
||||
.log
|
||||
.retryability
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "retryability",
|
||||
})?;
|
||||
let _certainty =
|
||||
request
|
||||
.log
|
||||
.outcome_certainty
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "outcome_certainty",
|
||||
})?;
|
||||
match request.log.status {
|
||||
crank_core::InvocationStatus::Ok if request.log.execution_error_code.is_some() => {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "execution_error_code",
|
||||
});
|
||||
}
|
||||
crank_core::InvocationStatus::Error if request.log.execution_error_code.is_none() => {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "execution_error_code",
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let request_preview = crank_core::sanitize_invocation_preview(&request.log.request_preview);
|
||||
let response_preview =
|
||||
crank_core::sanitize_invocation_preview(&request.log.response_preview);
|
||||
let operation_version =
|
||||
i32::try_from(operation_version).map_err(|_| RegistryError::InvalidNumericValue {
|
||||
field: "operation_version",
|
||||
value: i64::MAX,
|
||||
})?;
|
||||
let should_anchor_onboarding = request.log.source
|
||||
== crank_core::InvocationSource::AgentToolCall
|
||||
&& request.log.status == crank_core::InvocationStatus::Ok
|
||||
&& request.log.execution_stage == Some(crank_core::ExecutionStage::Runtime)
|
||||
&& request.log.outcome_certainty == Some(crank_core::OutcomeCertainty::Certain)
|
||||
&& request.log.execution_error_code.is_none()
|
||||
&& request.log.agent_id.is_some()
|
||||
&& request.log.platform_api_key_id.is_some();
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
if should_anchor_onboarding {
|
||||
sqlx::query("select id from workspaces where id = $1 for update")
|
||||
.bind(request.log.workspace_id.as_str())
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"insert into invocation_logs (
|
||||
id,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
platform_api_key_id,
|
||||
operation_id,
|
||||
operation_version,
|
||||
source,
|
||||
level,
|
||||
status,
|
||||
@@ -82,17 +219,29 @@ impl PostgresRegistry {
|
||||
status_code,
|
||||
duration_ms,
|
||||
error_kind,
|
||||
execution_stage,
|
||||
execution_error_code,
|
||||
retryability,
|
||||
outcome_certainty,
|
||||
request_preview_json,
|
||||
response_preview_json,
|
||||
created_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17::timestamptz
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23::timestamptz
|
||||
)",
|
||||
)
|
||||
.bind(request.log.id.as_str())
|
||||
.bind(request.log.workspace_id.as_str())
|
||||
.bind(request.log.agent_id.as_ref().map(|value| value.as_str()))
|
||||
.bind(
|
||||
request
|
||||
.log
|
||||
.platform_api_key_id
|
||||
.as_ref()
|
||||
.map(|value| value.as_str()),
|
||||
)
|
||||
.bind(request.log.operation_id.as_str())
|
||||
.bind(operation_version)
|
||||
.bind(serialize_enum_text(&request.log.source, "source")?)
|
||||
.bind(serialize_enum_text(&request.log.level, "level")?)
|
||||
.bind(serialize_enum_text(&request.log.status, "status")?)
|
||||
@@ -108,12 +257,157 @@ impl PostgresRegistry {
|
||||
}
|
||||
})?)
|
||||
.bind(&request.log.error_kind)
|
||||
.bind(
|
||||
request
|
||||
.log
|
||||
.execution_stage
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "execution_stage"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
request
|
||||
.log
|
||||
.execution_error_code
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "execution_error_code"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
request
|
||||
.log
|
||||
.retryability
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "retryability"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
request
|
||||
.log
|
||||
.outcome_certainty
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "outcome_certainty"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(Json(request_preview))
|
||||
.bind(Json(response_preview))
|
||||
.bind(request.log.created_at)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if should_anchor_onboarding {
|
||||
let agent_id =
|
||||
request
|
||||
.log
|
||||
.agent_id
|
||||
.as_ref()
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "onboarding.agent_id",
|
||||
})?;
|
||||
let key_id = request.log.platform_api_key_id.as_ref().ok_or(
|
||||
RegistryError::InvalidExecutionRecord {
|
||||
field: "onboarding.platform_api_key_id",
|
||||
},
|
||||
)?;
|
||||
let anchored = sqlx::query(
|
||||
"insert into onboarding_selections (
|
||||
workspace_id, operation_id, operation_version, agent_id, catalog_revision,
|
||||
platform_api_key_id, invocation_log_id, test_log_id, evidence_after, selected_at
|
||||
)
|
||||
select $1, $2, $3, a.id, pa.catalog_revision, k.id, $6,
|
||||
(
|
||||
select l.id from invocation_logs l
|
||||
where l.workspace_id = $1 and l.operation_id = $2
|
||||
and l.operation_version = $3 and l.source = 'admin_test_run'
|
||||
and l.status = 'ok' and l.execution_stage = 'runtime'
|
||||
and l.outcome_certainty = 'certain' and l.execution_error_code is null
|
||||
and l.created_at >= coalesce(
|
||||
(select s.evidence_after from onboarding_selections s
|
||||
where s.workspace_id = $1),
|
||||
'-infinity'::timestamptz
|
||||
)
|
||||
order by l.created_at desc, l.id desc limit 1
|
||||
),
|
||||
coalesce(
|
||||
(select s.evidence_after from onboarding_selections s
|
||||
where s.workspace_id = $1),
|
||||
'-infinity'::timestamptz
|
||||
),
|
||||
$7
|
||||
from agents a
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
join agent_operation_bindings b
|
||||
on b.agent_id = pa.agent_id and b.agent_version = pa.version
|
||||
and b.operation_id = $2 and b.operation_version = $3 and b.enabled
|
||||
join published_operations po on po.operation_id = b.operation_id and po.version = b.operation_version
|
||||
join operations o on o.id = po.operation_id and o.workspace_id = $1
|
||||
and o.status = 'published' and o.current_draft_version = po.version
|
||||
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id and k.id = $5
|
||||
where a.workspace_id = $1 and a.id = $4 and a.status = 'published'
|
||||
and k.key_kind = 'mcp_client' and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > $7)
|
||||
on conflict (workspace_id) do update
|
||||
set operation_id = excluded.operation_id,
|
||||
operation_version = excluded.operation_version,
|
||||
agent_id = excluded.agent_id,
|
||||
catalog_revision = excluded.catalog_revision,
|
||||
platform_api_key_id = excluded.platform_api_key_id,
|
||||
invocation_log_id = excluded.invocation_log_id,
|
||||
test_log_id = excluded.test_log_id,
|
||||
selected_at = excluded.selected_at
|
||||
where onboarding_selections.operation_id is null
|
||||
and excluded.selected_at >= onboarding_selections.evidence_after",
|
||||
)
|
||||
.bind(request.log.workspace_id.as_str())
|
||||
.bind(request.log.operation_id.as_str())
|
||||
.bind(operation_version)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.bind(request.log.id.as_str())
|
||||
.bind(request.log.created_at)
|
||||
.execute(&mut *transaction)
|
||||
.await?
|
||||
.rows_affected()
|
||||
> 0;
|
||||
if anchored {
|
||||
let projection = super::onboarding::get_onboarding_projection_in_transaction(
|
||||
&mut transaction,
|
||||
&request.log.workspace_id,
|
||||
)
|
||||
.await?;
|
||||
let eligible =
|
||||
super::product_event::get_product_event_by_idempotency_key_in_transaction(
|
||||
&mut transaction,
|
||||
&request.log.workspace_id,
|
||||
"onboarding:eligible:v1",
|
||||
)
|
||||
.await?;
|
||||
if let Some(eligible_since) = eligible
|
||||
.filter(|_| projection.completed)
|
||||
.and_then(|record| record.event.eligible_since)
|
||||
{
|
||||
let completed = crank_core::ProductEvent {
|
||||
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
|
||||
workspace_id: request.log.workspace_id.clone(),
|
||||
kind: crank_core::ProductEventKind::OnboardingCompleted,
|
||||
schema_version: crank_core::PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: true,
|
||||
eligible_since: Some(eligible_since),
|
||||
idempotency_key: "onboarding:completed:v1".to_owned(),
|
||||
occurred_at: request.log.created_at,
|
||||
};
|
||||
super::product_event::append_product_event_in_transaction(
|
||||
&mut transaction,
|
||||
&completed,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -121,12 +415,15 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
query: ListInvocationLogsQuery<'_>,
|
||||
) -> Result<Vec<InvocationLogRecord>, RegistryError> {
|
||||
let rows = sqlx::query(
|
||||
let outcome_group_case = usage_outcome_group_case_sql("l.");
|
||||
let sql = format!(
|
||||
"select
|
||||
l.id,
|
||||
l.workspace_id,
|
||||
l.agent_id,
|
||||
l.platform_api_key_id,
|
||||
l.operation_id,
|
||||
l.operation_version,
|
||||
l.source,
|
||||
l.level,
|
||||
l.status,
|
||||
@@ -137,6 +434,10 @@ impl PostgresRegistry {
|
||||
l.status_code,
|
||||
l.duration_ms,
|
||||
l.error_kind,
|
||||
l.execution_stage,
|
||||
l.execution_error_code,
|
||||
l.retryability,
|
||||
l.outcome_certainty,
|
||||
l.request_preview_json,
|
||||
l.response_preview_json,
|
||||
l.created_at as created_at,
|
||||
@@ -149,42 +450,67 @@ impl PostgresRegistry {
|
||||
left join agents a on a.id = l.agent_id
|
||||
where l.workspace_id = $1
|
||||
and ($2::text is null or l.level = $2)
|
||||
and ($3::text is null or l.source = $3)
|
||||
and ($4::text is null or l.operation_id = $4)
|
||||
and ($5::text is null or l.agent_id = $5)
|
||||
and ($6::timestamptz is null or l.created_at >= $6::timestamptz)
|
||||
and ($3::text is null or l.status = $3)
|
||||
and ($4::text is null or l.source = $4)
|
||||
and ($5::text is null or l.operation_id = $5)
|
||||
and ($6::text is null or l.agent_id = $6)
|
||||
and ($7::text is null or ({outcome_group_case}) = $7)
|
||||
and ($8::timestamptz is null or l.created_at >= $8::timestamptz)
|
||||
and ($9::timestamptz is null or l.created_at < $9::timestamptz)
|
||||
and (
|
||||
$7::text is null
|
||||
or l.tool_name ilike '%' || $7 || '%'
|
||||
or l.message ilike '%' || $7 || '%'
|
||||
or o.name ilike '%' || $7 || '%'
|
||||
or o.display_name ilike '%' || $7 || '%'
|
||||
$10::timestamptz is null
|
||||
or (l.created_at, l.id) < ($10::timestamptz, $11::text)
|
||||
)
|
||||
order by l.created_at desc
|
||||
limit $8",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(
|
||||
query
|
||||
.level
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "level"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "source"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(query.operation_id.map(|value| value.as_str()))
|
||||
.bind(query.agent_id.map(|value| value.as_str()))
|
||||
.bind(query.created_after)
|
||||
.bind(query.search_text)
|
||||
.bind(i64::from(query.limit))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
and (
|
||||
$12::text is null
|
||||
or l.tool_name ilike '%' || $12 || '%'
|
||||
or l.message ilike '%' || $12 || '%'
|
||||
or o.name ilike '%' || $12 || '%'
|
||||
or o.display_name ilike '%' || $12 || '%'
|
||||
)
|
||||
order by l.created_at desc, l.id desc
|
||||
limit $13"
|
||||
);
|
||||
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(
|
||||
query
|
||||
.level
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "level"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
query
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "status"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "source"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(query.operation_id.map(|value| value.as_str()))
|
||||
.bind(query.agent_id.map(|value| value.as_str()))
|
||||
.bind(
|
||||
query
|
||||
.outcome_group
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "outcome_group"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(query.cursor_created_at)
|
||||
.bind(query.cursor_id.map(|value| value.as_str()))
|
||||
.bind(query.search_text)
|
||||
.bind(i64::from(query.limit))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.iter().map(map_invocation_log_record).collect()
|
||||
}
|
||||
@@ -199,7 +525,9 @@ impl PostgresRegistry {
|
||||
l.id,
|
||||
l.workspace_id,
|
||||
l.agent_id,
|
||||
l.platform_api_key_id,
|
||||
l.operation_id,
|
||||
l.operation_version,
|
||||
l.source,
|
||||
l.level,
|
||||
l.status,
|
||||
@@ -210,6 +538,10 @@ impl PostgresRegistry {
|
||||
l.status_code,
|
||||
l.duration_ms,
|
||||
l.error_kind,
|
||||
l.execution_stage,
|
||||
l.execution_error_code,
|
||||
l.retryability,
|
||||
l.outcome_certainty,
|
||||
l.request_preview_json,
|
||||
l.response_preview_json,
|
||||
l.created_at as created_at,
|
||||
@@ -245,10 +577,12 @@ impl PostgresRegistry {
|
||||
from invocation_logs
|
||||
where workspace_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and ($3::text is null or source = $3)",
|
||||
and created_at < $3::timestamptz
|
||||
and ($4::text is null or source = $4)",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -300,13 +634,15 @@ impl PostgresRegistry {
|
||||
from invocation_logs
|
||||
where workspace_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and ($3::text is null or source = $3)
|
||||
and created_at < $3::timestamptz
|
||||
and ($4::text is null or source = $4)
|
||||
group by 1
|
||||
order by 1 asc"
|
||||
);
|
||||
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -347,12 +683,14 @@ impl PostgresRegistry {
|
||||
join operations o on o.id = l.operation_id
|
||||
where l.workspace_id = $1
|
||||
and l.created_at >= $2::timestamptz
|
||||
and ($3::text is null or l.source = $3)
|
||||
and l.created_at < $3::timestamptz
|
||||
and ($4::text is null or l.source = $4)
|
||||
group by o.id, o.name, o.display_name, o.protocol
|
||||
order by calls_total desc, o.name asc",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -383,11 +721,13 @@ impl PostgresRegistry {
|
||||
where workspace_id = $1
|
||||
and operation_id = $2
|
||||
and created_at >= $3::timestamptz
|
||||
and ($4::text is null or source = $4)",
|
||||
and created_at < $4::timestamptz
|
||||
and ($5::text is null or source = $5)",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(operation_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -437,12 +777,14 @@ impl PostgresRegistry {
|
||||
join agents a on a.id = l.agent_id
|
||||
where l.workspace_id = $1
|
||||
and l.created_at >= $2::timestamptz
|
||||
and ($3::text is null or l.source = $3)
|
||||
and l.created_at < $3::timestamptz
|
||||
and ($4::text is null or l.source = $4)
|
||||
group by a.id, a.slug, a.display_name
|
||||
order by calls_total desc, a.slug asc",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -473,11 +815,13 @@ impl PostgresRegistry {
|
||||
where workspace_id = $1
|
||||
and agent_id = $2
|
||||
and created_at >= $3::timestamptz
|
||||
and ($4::text is null or source = $4)",
|
||||
and created_at < $4::timestamptz
|
||||
and ($5::text is null or source = $5)",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -508,4 +852,78 @@ impl PostgresRegistry {
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_usage_outcomes(
|
||||
&self,
|
||||
query: UsageQuery<'_>,
|
||||
) -> Result<Vec<UsageOutcomeBreakdown>, RegistryError> {
|
||||
let outcome_group_case = usage_outcome_group_case_sql("");
|
||||
let sql = format!(
|
||||
"select
|
||||
{outcome_group_case} as outcome_group,
|
||||
execution_error_code,
|
||||
count(*)::bigint as calls_total,
|
||||
coalesce(percentile_cont(0.5) within group (order by duration_ms), 0)::bigint as p50_ms,
|
||||
coalesce(percentile_cont(0.95) within group (order by duration_ms), 0)::bigint as p95_ms,
|
||||
coalesce(percentile_cont(0.99) within group (order by duration_ms), 0)::bigint as p99_ms
|
||||
from invocation_logs
|
||||
where workspace_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and created_at < $3::timestamptz
|
||||
and ($4::text is null or source = $4)
|
||||
group by 1, execution_error_code
|
||||
order by outcome_group asc, execution_error_code asc nulls first"
|
||||
);
|
||||
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "source"))
|
||||
.transpose()?,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
Ok(UsageOutcomeBreakdown {
|
||||
group: deserialize_enum_text(
|
||||
&row.try_get::<String, _>("outcome_group")?,
|
||||
"outcome_group",
|
||||
)?,
|
||||
execution_error_code: row
|
||||
.try_get::<Option<String>, _>("execution_error_code")?
|
||||
.map(|value| deserialize_enum_text(&value, "execution_error_code"))
|
||||
.transpose()?,
|
||||
calls_total: to_u64(row.try_get::<i64, _>("calls_total")?, "calls_total")?,
|
||||
p50_ms: to_u64(row.try_get::<i64, _>("p50_ms")?, "p50_ms")?,
|
||||
p95_ms: to_u64(row.try_get::<i64, _>("p95_ms")?, "p95_ms")?,
|
||||
p99_ms: to_u64(row.try_get::<i64, _>("p99_ms")?, "p99_ms")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crank_core::ExecutionErrorCode;
|
||||
|
||||
use super::usage_outcome_group_case_sql;
|
||||
|
||||
#[test]
|
||||
fn usage_outcome_group_sql_covers_every_execution_error_code() {
|
||||
let sql = usage_outcome_group_case_sql("");
|
||||
for code in ExecutionErrorCode::ALL {
|
||||
assert!(
|
||||
sql.contains(code.as_str()),
|
||||
"missing execution outcome grouping for {}",
|
||||
code.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
use crank_core::product_event::PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX;
|
||||
use crank_core::{
|
||||
AgentId, InvocationLogId, OnboardingProjection, OnboardingStep, OnboardingStepId, OperationId,
|
||||
PRODUCT_EVENT_SCHEMA_VERSION, PlatformApiKeyId, ProductEvent, ProductEventKind,
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::*;
|
||||
|
||||
const ONBOARDING_PROJECTION_SQL: &str = r#"
|
||||
with selection_state as (
|
||||
select operation_id, operation_version, agent_id, catalog_revision,
|
||||
platform_api_key_id, invocation_log_id, test_log_id, evidence_after, selected_at
|
||||
from onboarding_selections
|
||||
where workspace_id = $1
|
||||
), operation_candidates as (
|
||||
select o.id, o.current_draft_version, o.latest_published_version, o.updated_at,
|
||||
coalesce(ss.operation_version, o.latest_published_version, o.current_draft_version) as target_version,
|
||||
(o.status <> 'archived') as operation_active,
|
||||
exists (
|
||||
select 1 from invocation_logs l
|
||||
where l.workspace_id = $1 and l.operation_id = o.id
|
||||
and l.operation_version = coalesce(ss.operation_version, o.latest_published_version, o.current_draft_version)
|
||||
and (ss.test_log_id is null or l.id = ss.test_log_id)
|
||||
and l.created_at >= coalesce(ss.evidence_after, '-infinity'::timestamptz)
|
||||
and l.source = 'admin_test_run' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null
|
||||
) as tested,
|
||||
exists (
|
||||
select 1 from published_operations po
|
||||
where po.operation_id = o.id
|
||||
and po.version = coalesce(ss.operation_version, o.latest_published_version)
|
||||
) as published,
|
||||
exists (
|
||||
select 1 from agent_operation_bindings b
|
||||
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
|
||||
join agents a on a.id = pa.agent_id
|
||||
where b.operation_id = o.id
|
||||
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
|
||||
and (ss.agent_id is null or a.id = ss.agent_id)
|
||||
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
|
||||
and b.enabled and a.workspace_id = $1 and a.status = 'published'
|
||||
and o.status <> 'archived'
|
||||
) as published_agent,
|
||||
exists (
|
||||
select 1 from agent_operation_bindings b
|
||||
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
|
||||
join agents a on a.id = pa.agent_id
|
||||
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id
|
||||
where b.operation_id = o.id
|
||||
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
|
||||
and (ss.agent_id is null or a.id = ss.agent_id)
|
||||
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
|
||||
and (ss.platform_api_key_id is null or k.id = ss.platform_api_key_id)
|
||||
and b.enabled and a.workspace_id = $1 and a.status = 'published'
|
||||
and o.status <> 'archived'
|
||||
and k.key_kind = 'mcp_client' and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())
|
||||
) as active_key,
|
||||
exists (
|
||||
select 1 from agent_operation_bindings b
|
||||
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
|
||||
join agents a on a.id = pa.agent_id
|
||||
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id
|
||||
join invocation_logs l
|
||||
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
|
||||
and l.operation_id = o.id and l.operation_version = b.operation_version
|
||||
where b.operation_id = o.id
|
||||
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
|
||||
and (ss.agent_id is null or a.id = ss.agent_id)
|
||||
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
|
||||
and (ss.platform_api_key_id is null or k.id = ss.platform_api_key_id)
|
||||
and b.enabled and a.workspace_id = $1 and a.status = 'published'
|
||||
and o.status <> 'archived'
|
||||
and k.key_kind = 'mcp_client' and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())
|
||||
and l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null and l.created_at >= k.created_at
|
||||
and l.created_at >= pa.published_at
|
||||
and l.created_at >= coalesce(ss.evidence_after, '-infinity'::timestamptz)
|
||||
and (ss.invocation_log_id is null or l.id = ss.invocation_log_id)
|
||||
) as first_call_complete
|
||||
, exists (
|
||||
select 1 from agent_operation_bindings b
|
||||
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
|
||||
join agents a on a.id = pa.agent_id
|
||||
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id
|
||||
join invocation_logs l
|
||||
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
|
||||
and l.operation_id = o.id and l.operation_version = b.operation_version
|
||||
where b.operation_id = o.id
|
||||
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
|
||||
and (ss.agent_id is null or a.id = ss.agent_id)
|
||||
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
|
||||
and (ss.platform_api_key_id is null or k.id = ss.platform_api_key_id)
|
||||
and b.enabled and a.workspace_id = $1
|
||||
and k.key_kind = 'mcp_client'
|
||||
and l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null and l.created_at >= k.created_at
|
||||
and l.created_at >= pa.published_at
|
||||
and l.created_at >= coalesce(ss.evidence_after, '-infinity'::timestamptz)
|
||||
and (ss.invocation_log_id is null or l.id = ss.invocation_log_id)
|
||||
) as historical_first_call
|
||||
from operations o
|
||||
left join selection_state ss on true
|
||||
where o.workspace_id = $1 and (ss.operation_id is null or o.id = ss.operation_id)
|
||||
), selected_operation as (
|
||||
select o.id, o.current_draft_version, o.latest_published_version, o.updated_at, o.target_version,
|
||||
o.operation_active
|
||||
from operation_candidates o
|
||||
order by o.historical_first_call desc, o.operation_active desc,
|
||||
o.first_call_complete desc, o.active_key desc, o.published_agent desc,
|
||||
o.published desc, o.tested desc, o.updated_at desc, o.id
|
||||
limit 1
|
||||
), operation_state as (
|
||||
select so.*,
|
||||
(so.operation_active and exists (
|
||||
select 1 from invocation_logs l
|
||||
where l.workspace_id = $1 and l.operation_id = so.id
|
||||
and l.operation_version = so.target_version
|
||||
and l.created_at >= coalesce((select evidence_after from selection_state), '-infinity'::timestamptz)
|
||||
and ((select test_log_id from selection_state) is null
|
||||
or l.id = (select test_log_id from selection_state))
|
||||
and l.source = 'admin_test_run' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null
|
||||
)) as tested,
|
||||
(so.operation_active and so.current_draft_version = so.target_version
|
||||
and po.operation_id is not null and po.version = so.target_version) as published,
|
||||
po.version as published_version
|
||||
from selected_operation so
|
||||
left join published_operations po on po.operation_id = so.id and po.version = so.target_version
|
||||
), agent_candidates as (
|
||||
select a.id, pa.catalog_revision, pa.published_at as agent_published_at,
|
||||
a.updated_at, b.operation_id, b.operation_version, os.operation_active,
|
||||
(os.published and a.status = 'published') as agent_active,
|
||||
exists (
|
||||
select 1 from platform_api_keys k
|
||||
where k.workspace_id = $1 and k.agent_id = a.id
|
||||
and os.operation_active and a.status = 'published'
|
||||
and k.key_kind = 'mcp_client' and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())
|
||||
) as active_key,
|
||||
exists (
|
||||
select 1 from platform_api_keys k
|
||||
join invocation_logs l
|
||||
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
|
||||
and l.operation_id = b.operation_id and l.operation_version = b.operation_version
|
||||
where k.workspace_id = $1 and k.agent_id = a.id
|
||||
and os.operation_active and a.status = 'published'
|
||||
and k.key_kind = 'mcp_client' and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())
|
||||
and l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null and l.created_at >= k.created_at
|
||||
and l.created_at >= pa.published_at
|
||||
) as first_call_complete
|
||||
, exists (
|
||||
select 1 from platform_api_keys k
|
||||
join invocation_logs l
|
||||
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
|
||||
and l.operation_id = b.operation_id and l.operation_version = b.operation_version
|
||||
where k.workspace_id = $1 and k.agent_id = a.id
|
||||
and k.key_kind = 'mcp_client'
|
||||
and l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null and l.created_at >= k.created_at
|
||||
and l.created_at >= pa.published_at
|
||||
) as historical_first_call
|
||||
from operation_state os
|
||||
join agent_operation_bindings b
|
||||
on b.operation_id = os.id and b.operation_version = os.published_version and b.enabled
|
||||
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
|
||||
join agents a on a.id = pa.agent_id
|
||||
where a.workspace_id = $1
|
||||
and ((select agent_id from selection_state) is null
|
||||
or a.id = (select agent_id from selection_state))
|
||||
and ((select catalog_revision from selection_state) is null
|
||||
or pa.catalog_revision = (select catalog_revision from selection_state))
|
||||
), selected_agent as (
|
||||
select id, catalog_revision, agent_published_at, updated_at, operation_id, operation_version,
|
||||
operation_active, agent_active
|
||||
from agent_candidates
|
||||
order by historical_first_call desc, agent_active desc,
|
||||
first_call_complete desc, active_key desc, updated_at desc, id
|
||||
limit 1
|
||||
), key_candidates as (
|
||||
select k.id, k.agent_id, k.last_used_at, k.created_at, k.expires_at, k.status,
|
||||
(sa.agent_active and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())) as key_active,
|
||||
exists (
|
||||
select 1 from invocation_logs l
|
||||
where l.workspace_id = $1 and l.agent_id = sa.id and l.platform_api_key_id = k.id
|
||||
and l.operation_id = sa.operation_id and l.operation_version = sa.operation_version
|
||||
and l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null and l.created_at >= k.created_at
|
||||
and l.created_at >= sa.agent_published_at
|
||||
) as first_call_complete
|
||||
from selected_agent sa
|
||||
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = sa.id
|
||||
where k.key_kind = 'mcp_client'
|
||||
and ((select platform_api_key_id from selection_state) is null
|
||||
or k.id = (select platform_api_key_id from selection_state))
|
||||
), selected_key as (
|
||||
select id, agent_id, last_used_at, created_at, expires_at, status, key_active
|
||||
from key_candidates
|
||||
order by first_call_complete desc, key_active desc, (last_used_at is not null) desc, created_at desc, id
|
||||
limit 1
|
||||
), first_call as (
|
||||
select l.id, l.tool_name, l.created_at, l.request_id, l.trace_id
|
||||
from selected_agent sa
|
||||
join selected_key sk on sk.agent_id = sa.id
|
||||
join invocation_logs l
|
||||
on l.workspace_id = $1 and l.agent_id = sa.id and l.platform_api_key_id = sk.id
|
||||
and l.operation_id = sa.operation_id and l.operation_version = sa.operation_version
|
||||
where l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and sk.key_active
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null
|
||||
and l.created_at >= sk.created_at
|
||||
and l.created_at >= sa.agent_published_at
|
||||
and l.created_at >= coalesce((select evidence_after from selection_state), '-infinity'::timestamptz)
|
||||
and ((select invocation_log_id from selection_state) is null
|
||||
or l.id = (select invocation_log_id from selection_state))
|
||||
order by l.created_at, l.id limit 1
|
||||
), event_state as (
|
||||
select count(*)::bigint as event_count, max(occurred_at) as last_event_at, max(id) as last_event_id,
|
||||
coalesce(bool_or(event_name = 'onboarding_completed'), false) as was_completed,
|
||||
min((properties_json ->> 'eligible_since')::timestamptz)
|
||||
filter (where event_name = 'onboarding_eligible') as eligible_since
|
||||
from product_events where workspace_id = $1
|
||||
)
|
||||
select os.id as operation_id, os.operation_active, os.tested, os.published, os.published_version,
|
||||
sa.id as agent_id, sa.agent_active, sa.catalog_revision, sk.id as platform_api_key_id,
|
||||
(sk.key_active and sk.last_used_at is not null) as connected, sk.key_active,
|
||||
fc.id as first_call_log_id, fc.tool_name as first_call_tool_name,
|
||||
fc.created_at as first_call_at, fc.request_id as first_call_request_id,
|
||||
fc.trace_id as first_call_trace_id, es.eligible_since, es.was_completed,
|
||||
(hashtextextended(concat_ws('|',
|
||||
coalesce(os.id, ''), coalesce(os.updated_at::text, ''), coalesce(os.tested::text, ''),
|
||||
coalesce(os.published_version::text, ''), coalesce(sa.id, ''),
|
||||
coalesce(sa.catalog_revision::text, ''), coalesce(sa.updated_at::text, ''),
|
||||
coalesce(sk.id, ''), coalesce(sk.status, ''), coalesce(sk.last_used_at::text, ''),
|
||||
coalesce(sk.expires_at::text, ''), coalesce(sk.key_active::text, ''),
|
||||
coalesce(fc.id, ''), coalesce(es.event_count::text, ''),
|
||||
coalesce(es.last_event_at::text, ''), coalesce(es.last_event_id, ''),
|
||||
coalesce((select evidence_after::text from selection_state), ''),
|
||||
coalesce((select selected_at::text from selection_state), '')
|
||||
), 0) & 9223372036854775807) as revision
|
||||
from event_state es
|
||||
left join operation_state os on true
|
||||
left join selected_agent sa on true
|
||||
left join selected_key sk on true
|
||||
left join first_call fc on true
|
||||
"#;
|
||||
|
||||
impl PostgresRegistry {
|
||||
/// Ensures the workspace enters the server-owned onboarding cohort exactly once.
|
||||
pub async fn ensure_onboarding_eligibility(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
occurred_at: OffsetDateTime,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
|
||||
let eligible = super::product_event::get_product_event_by_idempotency_key_in_transaction(
|
||||
&mut transaction,
|
||||
workspace_id,
|
||||
"onboarding:eligible:v1",
|
||||
)
|
||||
.await?;
|
||||
let eligible_since = if let Some(record) = eligible {
|
||||
record.event.eligible_since.unwrap_or(occurred_at)
|
||||
} else {
|
||||
let event = ProductEvent {
|
||||
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
|
||||
workspace_id: workspace_id.clone(),
|
||||
kind: ProductEventKind::OnboardingEligible,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: true,
|
||||
eligible_since: Some(occurred_at),
|
||||
idempotency_key: "onboarding:eligible:v1".to_owned(),
|
||||
occurred_at,
|
||||
};
|
||||
super::product_event::append_product_event_in_transaction(&mut transaction, &event)
|
||||
.await?;
|
||||
occurred_at
|
||||
};
|
||||
let mut projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
if projection.completed && !projection.was_completed {
|
||||
let event = ProductEvent {
|
||||
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
|
||||
workspace_id: workspace_id.clone(),
|
||||
kind: ProductEventKind::OnboardingCompleted,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: true,
|
||||
eligible_since: Some(eligible_since),
|
||||
idempotency_key: "onboarding:completed:v1".to_owned(),
|
||||
occurred_at: projection.first_call_at.unwrap_or(occurred_at),
|
||||
};
|
||||
super::product_event::append_product_event_in_transaction(&mut transaction, &event)
|
||||
.await?;
|
||||
projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(projection)
|
||||
}
|
||||
|
||||
/// Records the server-owned completion once the authoritative projection is terminal.
|
||||
pub async fn ensure_onboarding_completion(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
_occurred_at: OffsetDateTime,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
|
||||
let projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
if !projection.completed {
|
||||
transaction.commit().await?;
|
||||
return Ok(projection);
|
||||
}
|
||||
let Some(eligible_since) = projection.eligible_since else {
|
||||
transaction.commit().await?;
|
||||
return Ok(projection);
|
||||
};
|
||||
let event = ProductEvent {
|
||||
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
|
||||
workspace_id: workspace_id.clone(),
|
||||
kind: ProductEventKind::OnboardingCompleted,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: true,
|
||||
eligible_since: Some(eligible_since),
|
||||
idempotency_key: "onboarding:completed:v1".to_owned(),
|
||||
occurred_at: projection
|
||||
.first_call_at
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "onboarding.first_call_at",
|
||||
})?,
|
||||
};
|
||||
super::product_event::append_product_event_in_transaction(&mut transaction, &event).await?;
|
||||
let projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(projection)
|
||||
}
|
||||
|
||||
pub async fn get_onboarding_projection(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let row = sqlx::query(ONBOARDING_PROJECTION_SQL)
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
map_projection(workspace_id, &row)
|
||||
}
|
||||
|
||||
pub async fn reset_onboarding_selection(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
expected_revision: i64,
|
||||
occurred_at: OffsetDateTime,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
|
||||
let current =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
if current.revision != expected_revision {
|
||||
return Err(RegistryError::OnboardingStaleRevision);
|
||||
}
|
||||
sqlx::query(
|
||||
"insert into onboarding_selections (
|
||||
workspace_id, operation_id, operation_version, agent_id, catalog_revision,
|
||||
platform_api_key_id, invocation_log_id, test_log_id, evidence_after, selected_at
|
||||
) values ($1, null, null, null, null, null, null, null, $2, null)
|
||||
on conflict (workspace_id) do update
|
||||
set operation_id = null, operation_version = null, agent_id = null,
|
||||
catalog_revision = null, platform_api_key_id = null,
|
||||
invocation_log_id = null, test_log_id = null,
|
||||
evidence_after = excluded.evidence_after, selected_at = null",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(occurred_at)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
let projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(projection)
|
||||
}
|
||||
|
||||
pub async fn record_onboarding_milestone(
|
||||
&self,
|
||||
request: RecordOnboardingMilestoneRequest<'_>,
|
||||
) -> Result<OnboardingMilestoneResult, RegistryError> {
|
||||
let kind = match request.milestone {
|
||||
OnboardingPresentationMilestone::Eligible => ProductEventKind::OnboardingEligible,
|
||||
OnboardingPresentationMilestone::Started => ProductEventKind::OnboardingStarted,
|
||||
OnboardingPresentationMilestone::Resumed => ProductEventKind::OnboardingResumed,
|
||||
OnboardingPresentationMilestone::Dismissed => ProductEventKind::OnboardingDismissed,
|
||||
OnboardingPresentationMilestone::Abandoned => ProductEventKind::OnboardingAbandoned,
|
||||
};
|
||||
self.record_onboarding_event(
|
||||
request.workspace_id,
|
||||
request.event_id,
|
||||
kind,
|
||||
request.idempotency_key,
|
||||
request.expected_revision,
|
||||
request.occurred_at,
|
||||
request.eligible_since,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn record_onboarding_completion(
|
||||
&self,
|
||||
request: RecordOnboardingCompletionRequest<'_>,
|
||||
) -> Result<OnboardingMilestoneResult, RegistryError> {
|
||||
self.record_onboarding_event(
|
||||
request.workspace_id,
|
||||
request.event_id,
|
||||
ProductEventKind::OnboardingCompleted,
|
||||
request.idempotency_key,
|
||||
request.expected_revision,
|
||||
request.occurred_at,
|
||||
Some(request.eligible_since),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn record_onboarding_event(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
event_id: &ProductEventId,
|
||||
kind: ProductEventKind,
|
||||
idempotency_key: &str,
|
||||
expected_revision: i64,
|
||||
occurred_at: OffsetDateTime,
|
||||
eligible_since: Option<OffsetDateTime>,
|
||||
) -> Result<OnboardingMilestoneResult, RegistryError> {
|
||||
if idempotency_key.starts_with(PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX) {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.reserved_idempotency_key",
|
||||
});
|
||||
}
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
|
||||
let current =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
if kind == ProductEventKind::OnboardingCompleted && !current.completed {
|
||||
return Err(RegistryError::OnboardingIncomplete);
|
||||
}
|
||||
let event = ProductEvent {
|
||||
id: event_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
kind,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: eligible_since.is_some(),
|
||||
eligible_since,
|
||||
idempotency_key: idempotency_key.to_owned(),
|
||||
occurred_at,
|
||||
};
|
||||
if current.revision != expected_revision {
|
||||
let replay = super::product_event::get_product_event_by_idempotency_key_in_transaction(
|
||||
&mut transaction,
|
||||
workspace_id,
|
||||
idempotency_key,
|
||||
)
|
||||
.await?;
|
||||
if replay.is_some_and(|record| event.is_semantic_replay_of(&record.event)) {
|
||||
transaction.commit().await?;
|
||||
return Ok(OnboardingMilestoneResult {
|
||||
accepted: false,
|
||||
projection: current,
|
||||
});
|
||||
}
|
||||
return Err(RegistryError::OnboardingStaleRevision);
|
||||
}
|
||||
let outcome =
|
||||
super::product_event::append_product_event_in_transaction(&mut transaction, &event)
|
||||
.await?;
|
||||
let projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(OnboardingMilestoneResult {
|
||||
accepted: outcome == AppendProductEventOutcome::Recorded,
|
||||
projection,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn lock_onboarding_workspace(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<(), RegistryError> {
|
||||
let present = sqlx::query("select id from workspaces where id = $1 for update")
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?
|
||||
.is_some();
|
||||
if !present {
|
||||
return Err(RegistryError::WorkspaceNotFound {
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn get_onboarding_projection_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let row = sqlx::query(ONBOARDING_PROJECTION_SQL)
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_one(&mut **transaction)
|
||||
.await?;
|
||||
map_projection(workspace_id, &row)
|
||||
}
|
||||
|
||||
fn map_projection(
|
||||
workspace_id: &WorkspaceId,
|
||||
row: &sqlx::postgres::PgRow,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let operation_id = row
|
||||
.try_get::<Option<String>, _>("operation_id")?
|
||||
.map(OperationId::new);
|
||||
let operation_active = row
|
||||
.try_get::<Option<bool>, _>("operation_active")?
|
||||
.unwrap_or(false);
|
||||
let tested = row.try_get::<Option<bool>, _>("tested")?.unwrap_or(false);
|
||||
let published = row
|
||||
.try_get::<Option<bool>, _>("published")?
|
||||
.unwrap_or(false);
|
||||
let agent_id = row
|
||||
.try_get::<Option<String>, _>("agent_id")?
|
||||
.map(AgentId::new);
|
||||
let agent_active = row
|
||||
.try_get::<Option<bool>, _>("agent_active")?
|
||||
.unwrap_or(false);
|
||||
let key_id = row
|
||||
.try_get::<Option<String>, _>("platform_api_key_id")?
|
||||
.map(PlatformApiKeyId::new);
|
||||
let key_active = row
|
||||
.try_get::<Option<bool>, _>("key_active")?
|
||||
.unwrap_or(false);
|
||||
let connected = row
|
||||
.try_get::<Option<bool>, _>("connected")?
|
||||
.unwrap_or(false);
|
||||
let first_call_log_id = row
|
||||
.try_get::<Option<String>, _>("first_call_log_id")?
|
||||
.map(InvocationLogId::new);
|
||||
let flags = [
|
||||
operation_id.is_some() && operation_active,
|
||||
tested,
|
||||
published,
|
||||
agent_id.is_some() && agent_active,
|
||||
key_id.is_some() && key_active,
|
||||
connected,
|
||||
first_call_log_id.is_some(),
|
||||
];
|
||||
Ok(OnboardingProjection {
|
||||
workspace_id: workspace_id.clone(),
|
||||
revision: row.try_get("revision")?,
|
||||
completed: flags.into_iter().all(|flag| flag),
|
||||
was_completed: row.try_get("was_completed")?,
|
||||
steps: OnboardingStepId::ORDERED
|
||||
.into_iter()
|
||||
.zip(flags)
|
||||
.map(|(id, completed)| OnboardingStep { id, completed })
|
||||
.collect(),
|
||||
operation_id,
|
||||
operation_version: row
|
||||
.try_get::<Option<i32>, _>("published_version")?
|
||||
.map(|value| {
|
||||
u32::try_from(value).map_err(|_| RegistryError::InvalidNumericValue {
|
||||
field: "onboarding.operation_version",
|
||||
value: i64::from(value),
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
agent_id,
|
||||
catalog_revision: row.try_get("catalog_revision")?,
|
||||
platform_api_key_id: key_id,
|
||||
first_call_log_id,
|
||||
first_call_tool_name: row.try_get("first_call_tool_name")?,
|
||||
first_call_at: row.try_get("first_call_at")?,
|
||||
first_call_request_id: row.try_get("first_call_request_id")?,
|
||||
first_call_trace_id: row.try_get("first_call_trace_id")?,
|
||||
eligible_since: row.try_get("eligible_since")?,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
use super::*;
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn get_published_operation(
|
||||
&self,
|
||||
operation_id: &OperationId,
|
||||
) -> Result<Option<RegistryOperation>, RegistryError> {
|
||||
let row = sqlx::query!(
|
||||
"select
|
||||
o.id,
|
||||
o.workspace_id,
|
||||
ov.name,
|
||||
ov.display_name,
|
||||
ov.category,
|
||||
ov.protocol,
|
||||
ov.security_level,
|
||||
ov.created_at as \"operation_created_at!: time::OffsetDateTime\",
|
||||
ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",
|
||||
ov.published_at as \"operation_published_at: time::OffsetDateTime\",
|
||||
ov.version,
|
||||
ov.status,
|
||||
ov.target_json,
|
||||
ov.input_schema_json,
|
||||
ov.output_schema_json,
|
||||
ov.input_mapping_json,
|
||||
ov.output_mapping_json,
|
||||
ov.execution_config_json,
|
||||
ov.tool_description_json,
|
||||
ov.samples_json,
|
||||
ov.generated_draft_json,
|
||||
ov.config_export_json,
|
||||
ov.wizard_state_json,
|
||||
ov.change_note,
|
||||
ov.created_at as \"created_at!: time::OffsetDateTime\",
|
||||
ov.created_by
|
||||
from published_operations po
|
||||
join operation_versions ov
|
||||
on ov.operation_id = po.operation_id and ov.version = po.version
|
||||
join operations o on o.id = po.operation_id
|
||||
where po.operation_id = $1",
|
||||
operation_id.as_str(),
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| {
|
||||
build_operation_version_record(
|
||||
row.id,
|
||||
row.workspace_id,
|
||||
row.name,
|
||||
row.display_name,
|
||||
row.category,
|
||||
row.protocol,
|
||||
row.security_level,
|
||||
row.operation_created_at,
|
||||
row.operation_updated_at,
|
||||
row.operation_published_at,
|
||||
row.version,
|
||||
row.status,
|
||||
row.target_json,
|
||||
row.input_schema_json,
|
||||
row.output_schema_json,
|
||||
row.input_mapping_json,
|
||||
row.output_mapping_json,
|
||||
row.execution_config_json,
|
||||
row.tool_description_json,
|
||||
row.samples_json,
|
||||
row.generated_draft_json,
|
||||
row.config_export_json,
|
||||
row.wizard_state_json,
|
||||
row.change_note,
|
||||
row.created_at,
|
||||
row.created_by,
|
||||
)
|
||||
.map(|record| record.snapshot)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn list_published_operations(&self) -> Result<Vec<RegistryOperation>, RegistryError> {
|
||||
let rows = sqlx::query!(
|
||||
"select
|
||||
o.id,
|
||||
o.workspace_id,
|
||||
ov.name,
|
||||
ov.display_name,
|
||||
ov.category,
|
||||
ov.protocol,
|
||||
ov.security_level,
|
||||
ov.created_at as \"operation_created_at!: time::OffsetDateTime\",
|
||||
ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",
|
||||
ov.published_at as \"operation_published_at: time::OffsetDateTime\",
|
||||
ov.version,
|
||||
ov.status,
|
||||
ov.target_json,
|
||||
ov.input_schema_json,
|
||||
ov.output_schema_json,
|
||||
ov.input_mapping_json,
|
||||
ov.output_mapping_json,
|
||||
ov.execution_config_json,
|
||||
ov.tool_description_json,
|
||||
ov.samples_json,
|
||||
ov.generated_draft_json,
|
||||
ov.config_export_json,
|
||||
ov.wizard_state_json,
|
||||
ov.change_note,
|
||||
ov.created_at as \"created_at!: time::OffsetDateTime\",
|
||||
ov.created_by
|
||||
from published_operations po
|
||||
join operation_versions ov
|
||||
on ov.operation_id = po.operation_id and ov.version = po.version
|
||||
join operations o on o.id = po.operation_id
|
||||
order by o.name asc",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
build_operation_version_record(
|
||||
row.id,
|
||||
row.workspace_id,
|
||||
row.name,
|
||||
row.display_name,
|
||||
row.category,
|
||||
row.protocol,
|
||||
row.security_level,
|
||||
row.operation_created_at,
|
||||
row.operation_updated_at,
|
||||
row.operation_published_at,
|
||||
row.version,
|
||||
row.status,
|
||||
row.target_json,
|
||||
row.input_schema_json,
|
||||
row.output_schema_json,
|
||||
row.input_mapping_json,
|
||||
row.output_mapping_json,
|
||||
row.execution_config_json,
|
||||
row.tool_description_json,
|
||||
row.samples_json,
|
||||
row.generated_draft_json,
|
||||
row.config_export_json,
|
||||
row.wizard_state_json,
|
||||
row.change_note,
|
||||
row.created_at,
|
||||
row.created_by,
|
||||
)
|
||||
.map(|record| record.snapshot)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use crank_core::{
|
||||
OnboardingMilestone, PRODUCT_EVENT_SCHEMA_VERSION, ProductEvent, ProductEventKind,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sqlx::{Postgres, Row, Transaction, types::Json};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn append_product_event(
|
||||
&self,
|
||||
request: AppendProductEventRequest<'_>,
|
||||
) -> Result<AppendProductEventOutcome, RegistryError> {
|
||||
if request
|
||||
.event
|
||||
.idempotency_key
|
||||
.starts_with(crank_core::product_event::PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX)
|
||||
{
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.reserved_idempotency_key",
|
||||
});
|
||||
}
|
||||
if !request.event.is_valid() {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event",
|
||||
});
|
||||
}
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let outcome = append_product_event_in_transaction(&mut transaction, request.event).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub async fn get_product_event_by_idempotency_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
idempotency_key: &str,
|
||||
) -> Result<Option<ProductEventRecord>, RegistryError> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let record = get_product_event_by_idempotency_key_in_transaction(
|
||||
&mut transaction,
|
||||
workspace_id,
|
||||
idempotency_key,
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn list_product_events(
|
||||
&self,
|
||||
query: ListProductEventsQuery<'_>,
|
||||
) -> Result<Vec<ProductEventRecord>, RegistryError> {
|
||||
let rows = sqlx::query(
|
||||
"select id, workspace_id, event_name, schema_version, occurred_at,
|
||||
idempotency_key, properties_json
|
||||
from product_events
|
||||
where workspace_id = $1
|
||||
and ($2::text is null or event_name = $2)
|
||||
and occurred_at >= $3 and occurred_at < $4
|
||||
order by occurred_at desc, id desc
|
||||
limit $5",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.kind.map(ProductEventKind::as_str))
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(i64::from(query.limit.min(1_000)))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(map_product_event).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn get_product_event_by_idempotency_key_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
idempotency_key: &str,
|
||||
) -> Result<Option<ProductEventRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select id, workspace_id, event_name, schema_version, occurred_at,
|
||||
idempotency_key, properties_json
|
||||
from product_events
|
||||
where workspace_id = $1 and idempotency_key = $2",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(idempotency_key)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?;
|
||||
row.map(map_product_event).transpose()
|
||||
}
|
||||
|
||||
pub(super) async fn append_product_event_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
event: &ProductEvent,
|
||||
) -> Result<AppendProductEventOutcome, RegistryError> {
|
||||
if !event.is_valid() {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event",
|
||||
});
|
||||
}
|
||||
let eligible_since = event
|
||||
.eligible_since
|
||||
.map(|value| value.format(&Rfc3339))
|
||||
.transpose()
|
||||
.map_err(|_| RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.eligible_since",
|
||||
})?;
|
||||
let properties = json!({
|
||||
"eligible": event.eligible,
|
||||
"eligible_since": eligible_since,
|
||||
"milestone": event.milestone,
|
||||
});
|
||||
let inserted = sqlx::query(
|
||||
"insert into product_events (
|
||||
id, workspace_id, event_name, schema_version, occurred_at,
|
||||
idempotency_key, properties_json
|
||||
) values ($1, $2, $3, $4, $5, $6, $7)
|
||||
on conflict (workspace_id, idempotency_key) do nothing",
|
||||
)
|
||||
.bind(event.id.as_str())
|
||||
.bind(event.workspace_id.as_str())
|
||||
.bind(event.kind.as_str())
|
||||
.bind(i32::from(event.schema_version))
|
||||
.bind(event.occurred_at)
|
||||
.bind(&event.idempotency_key)
|
||||
.bind(Json(properties))
|
||||
.execute(&mut **transaction)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if inserted == 0 {
|
||||
let recorded = sqlx::query(
|
||||
"select id, workspace_id, event_name, schema_version, occurred_at,
|
||||
idempotency_key, properties_json
|
||||
from product_events
|
||||
where workspace_id = $1 and idempotency_key = $2",
|
||||
)
|
||||
.bind(event.workspace_id.as_str())
|
||||
.bind(&event.idempotency_key)
|
||||
.fetch_one(&mut **transaction)
|
||||
.await?;
|
||||
let recorded = map_product_event(recorded)?;
|
||||
if event.is_semantic_replay_of(&recorded.event) {
|
||||
return Ok(AppendProductEventOutcome::Duplicate);
|
||||
}
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.idempotency_conflict",
|
||||
});
|
||||
}
|
||||
sqlx::query(
|
||||
"insert into product_event_daily_rollups (
|
||||
workspace_id, event_name, day, events_total, eligible_total
|
||||
) values ($1, $2, $3, 1, $4)
|
||||
on conflict (workspace_id, event_name, day) do update
|
||||
set events_total = product_event_daily_rollups.events_total + 1,
|
||||
eligible_total = product_event_daily_rollups.eligible_total + excluded.eligible_total",
|
||||
)
|
||||
.bind(event.workspace_id.as_str())
|
||||
.bind(event.kind.as_str())
|
||||
.bind(event.occurred_on_utc())
|
||||
.bind(i64::from(
|
||||
event.kind == ProductEventKind::OnboardingEligible,
|
||||
))
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(AppendProductEventOutcome::Recorded)
|
||||
}
|
||||
|
||||
fn map_product_event(row: sqlx::postgres::PgRow) -> Result<ProductEventRecord, RegistryError> {
|
||||
let event_name = row.try_get::<String, _>("event_name")?;
|
||||
let kind = ProductEventKind::ALL
|
||||
.into_iter()
|
||||
.find(|kind| kind.as_str() == event_name)
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.event_name",
|
||||
})?;
|
||||
let properties = row.try_get::<serde_json::Value, _>("properties_json")?;
|
||||
let milestone = properties
|
||||
.get("milestone")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(|value| serde_json::from_value::<OnboardingMilestone>(value.clone()))
|
||||
.transpose()
|
||||
.map_err(|_| RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.milestone",
|
||||
})?;
|
||||
let eligible_since = properties
|
||||
.get("eligible_since")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.ok_or(())
|
||||
.and_then(|value| OffsetDateTime::parse(value, &Rfc3339).map_err(|_| ()))
|
||||
})
|
||||
.transpose()
|
||||
.map_err(|_| RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.eligible_since",
|
||||
})?;
|
||||
let schema_version = row.try_get::<i32, _>("schema_version")?;
|
||||
let schema_version =
|
||||
u16::try_from(schema_version).map_err(|_| RegistryError::InvalidNumericValue {
|
||||
field: "product_event.schema_version",
|
||||
value: i64::from(schema_version),
|
||||
})?;
|
||||
if schema_version != PRODUCT_EVENT_SCHEMA_VERSION {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.schema_version",
|
||||
});
|
||||
}
|
||||
Ok(ProductEventRecord {
|
||||
event: ProductEvent {
|
||||
id: ProductEventId::new(row.try_get::<String, _>("id")?),
|
||||
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
|
||||
kind,
|
||||
schema_version,
|
||||
milestone,
|
||||
eligible: properties
|
||||
.get("eligible")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
eligible_since,
|
||||
idempotency_key: row.try_get("idempotency_key")?,
|
||||
occurred_at: row.try_get("occurred_at")?,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::master_key::{ensure_active_master_key_epoch, ensure_no_active_master_key_rotation};
|
||||
use super::*;
|
||||
|
||||
impl PostgresRegistry {
|
||||
@@ -90,34 +91,42 @@ impl PostgresRegistry {
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<Option<SecretVersionRecord>, RegistryError> {
|
||||
let row = sqlx::query!(
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
sv.secret_id,
|
||||
sv.version,
|
||||
sv.ciphertext,
|
||||
sv.key_version,
|
||||
sv.created_at as \"created_at!: time::OffsetDateTime\",
|
||||
sv.master_key_epoch,
|
||||
sv.target_ciphertext,
|
||||
sv.target_key_version,
|
||||
sv.target_master_key_epoch,
|
||||
sv.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",
|
||||
workspace_id.as_str(),
|
||||
secret_id.as_str(),
|
||||
where s.workspace_id = $1 and s.id = $2 and s.status = 'active'",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| {
|
||||
Ok(SecretVersionRecord {
|
||||
secret_version: SecretVersion {
|
||||
secret_id: SecretId::new(row.secret_id),
|
||||
version: from_db_version(row.version, "version")?,
|
||||
ciphertext: row.ciphertext,
|
||||
key_version: row.key_version,
|
||||
created_at: row.created_at,
|
||||
created_by: row.created_by.map(UserId::new),
|
||||
secret_id: SecretId::new(row.get::<String, _>("secret_id")),
|
||||
version: from_db_version(row.get::<i32, _>("version"), "version")?,
|
||||
ciphertext: row.get::<String, _>("ciphertext"),
|
||||
key_version: row.get::<String, _>("key_version"),
|
||||
created_at: row.get("created_at"),
|
||||
created_by: row.get::<Option<String>, _>("created_by").map(UserId::new),
|
||||
},
|
||||
master_key_epoch: row.get::<i64, _>("master_key_epoch"),
|
||||
target_ciphertext: row.get::<Option<String>, _>("target_ciphertext"),
|
||||
target_key_version: row.get::<Option<String>, _>("target_key_version"),
|
||||
target_master_key_epoch: row.get::<Option<i64>, _>("target_master_key_epoch"),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
@@ -128,6 +137,8 @@ impl PostgresRegistry {
|
||||
request: CreateSecretRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
ensure_no_active_master_key_rotation(&mut tx).await?;
|
||||
ensure_active_master_key_epoch(&mut tx, request.master_key_epoch).await?;
|
||||
let result = sqlx::query(
|
||||
"insert into secrets (
|
||||
id,
|
||||
@@ -163,16 +174,18 @@ impl PostgresRegistry {
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
) values (
|
||||
$1, $2, $3, $4, $5::timestamptz, $6
|
||||
$1, $2, $3, $4, $5, $6::timestamptz, $7
|
||||
)",
|
||||
)
|
||||
.bind(request.secret.id.as_str())
|
||||
.bind(to_db_version(request.secret.current_version))
|
||||
.bind(request.ciphertext)
|
||||
.bind(request.key_version)
|
||||
.bind(request.master_key_epoch)
|
||||
.bind(request.secret.created_at)
|
||||
.bind(request.created_by.map(|value| value.as_str()))
|
||||
.execute(&mut *tx)
|
||||
@@ -196,31 +209,56 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: RotateSecretRequest<'_>,
|
||||
) -> Result<SecretVersionRecord, RegistryError> {
|
||||
let existing = self
|
||||
.get_secret(request.workspace_id, request.secret_id)
|
||||
.await?
|
||||
.ok_or_else(|| RegistryError::SecretNotFound {
|
||||
secret_id: request.secret_id.as_str().to_owned(),
|
||||
})?;
|
||||
let next_version = existing.secret.current_version + 1;
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
ensure_no_active_master_key_rotation(&mut tx).await?;
|
||||
ensure_active_master_key_epoch(&mut tx, request.master_key_epoch).await?;
|
||||
let row = sqlx::query(
|
||||
"select current_version, status
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(request.secret_id.as_str())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(RegistryError::SecretNotFound {
|
||||
secret_id: request.secret_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
let status: String = row.get("status");
|
||||
if status != "active" {
|
||||
return Err(RegistryError::SecretInactive {
|
||||
secret_id: request.secret_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
let current_version =
|
||||
from_db_version(row.get::<i32, _>("current_version"), "current_version")?;
|
||||
let next_version = current_version.checked_add(1).ok_or_else(|| {
|
||||
RegistryError::SecretConcurrentUpdate {
|
||||
secret_id: request.secret_id.as_str().to_owned(),
|
||||
}
|
||||
})?;
|
||||
|
||||
sqlx::query(
|
||||
"insert into secret_versions (
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
) values (
|
||||
$1, $2, $3, $4, $5::timestamptz, $6
|
||||
$1, $2, $3, $4, $5, $6::timestamptz, $7
|
||||
)",
|
||||
)
|
||||
.bind(request.secret_id.as_str())
|
||||
.bind(to_db_version(next_version))
|
||||
.bind(request.ciphertext)
|
||||
.bind(request.key_version)
|
||||
.bind(request.master_key_epoch)
|
||||
.bind(request.created_at)
|
||||
.bind(request.created_by.map(|value| value.as_str()))
|
||||
.execute(&mut *tx)
|
||||
@@ -230,14 +268,31 @@ impl PostgresRegistry {
|
||||
"update secrets
|
||||
set current_version = $3,
|
||||
updated_at = $4::timestamptz
|
||||
where workspace_id = $1 and id = $2",
|
||||
where workspace_id = $1
|
||||
and id = $2
|
||||
and status = 'active'
|
||||
and current_version = $5",
|
||||
)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(request.secret_id.as_str())
|
||||
.bind(to_db_version(next_version))
|
||||
.bind(request.updated_at)
|
||||
.bind(to_db_version(current_version))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
.await
|
||||
.and_then(|result| {
|
||||
if result.rows_affected() == 1 {
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(sqlx::Error::RowNotFound)
|
||||
}
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
sqlx::Error::RowNotFound => RegistryError::SecretConcurrentUpdate {
|
||||
secret_id: request.secret_id.as_str().to_owned(),
|
||||
},
|
||||
other => RegistryError::Storage(other),
|
||||
})?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -250,6 +305,10 @@ impl PostgresRegistry {
|
||||
created_at: *request.created_at,
|
||||
created_by: request.created_by.cloned(),
|
||||
},
|
||||
master_key_epoch: request.master_key_epoch,
|
||||
target_ciphertext: None,
|
||||
target_key_version: None,
|
||||
target_master_key_epoch: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -258,13 +317,24 @@ impl PostgresRegistry {
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
ensure_no_active_master_key_rotation(&mut tx).await?;
|
||||
lock_secret_reference(&mut tx, workspace_id, secret_id).await?;
|
||||
if let Some(auth_profile_id) =
|
||||
first_auth_profile_ref_in_tx(&mut tx, workspace_id, secret_id).await?
|
||||
{
|
||||
return Err(RegistryError::SecretReferencedByAuthProfile {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
auth_profile_id,
|
||||
});
|
||||
}
|
||||
let result = sqlx::query(
|
||||
"delete from secrets
|
||||
where workspace_id = $1 and id = $2",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
@@ -273,6 +343,7 @@ impl PostgresRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -282,23 +353,30 @@ impl PostgresRegistry {
|
||||
secret_id: &SecretId,
|
||||
used_at: &OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"with target as (
|
||||
select id
|
||||
let row = sqlx::query(
|
||||
"with target as materialized (
|
||||
select id,
|
||||
last_used_at,
|
||||
status = 'active' as active
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2
|
||||
for update
|
||||
), updated as (
|
||||
update secrets
|
||||
update secrets as secret
|
||||
set last_used_at = $3::timestamptz
|
||||
where workspace_id = $1
|
||||
and id = $2
|
||||
from target
|
||||
where secret.workspace_id = $1
|
||||
and secret.id = target.id
|
||||
and target.active
|
||||
and (
|
||||
last_used_at is null
|
||||
or last_used_at < $3::timestamptz - interval '1 minute'
|
||||
target.last_used_at is null
|
||||
or target.last_used_at < $3::timestamptz - interval '1 minute'
|
||||
)
|
||||
returning id
|
||||
returning secret.id
|
||||
)
|
||||
select exists(select 1 from target)",
|
||||
select
|
||||
exists(select 1 from target) as exists,
|
||||
coalesce((select active from target), false) as active",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
@@ -306,12 +384,74 @@ impl PostgresRegistry {
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if !exists {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::SecretNotFound {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("active") {
|
||||
return Err(RegistryError::SecretInactive {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn lock_secret_reference(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"select id
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn first_auth_profile_ref_in_tx(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<Option<String>, RegistryError> {
|
||||
let rows = sqlx::query(
|
||||
"select id, name, kind, config_json, created_at, updated_at
|
||||
from auth_profiles
|
||||
where workspace_id = $1
|
||||
order by id asc
|
||||
for update",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_all(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
for row in rows {
|
||||
let profile = super::build_auth_profile(
|
||||
row.get("id"),
|
||||
workspace_id.as_str().to_owned(),
|
||||
row.get("name"),
|
||||
row.get("kind"),
|
||||
row.get("config_json"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
)?;
|
||||
if profile
|
||||
.config
|
||||
.secret_ids()
|
||||
.into_iter()
|
||||
.any(|candidate| candidate == secret_id)
|
||||
{
|
||||
return Ok(Some(profile.id.as_str().to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
mod integration {
|
||||
mod agents_usage;
|
||||
mod approval;
|
||||
mod common;
|
||||
mod credential_touch;
|
||||
mod master_key_identity;
|
||||
mod migrations;
|
||||
mod observability;
|
||||
mod onboarding;
|
||||
mod operations_artifacts;
|
||||
mod workspace_access;
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@ use crank_registry::{
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
|
||||
DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry,
|
||||
PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, UsageBucket, UsageOutcomeGroup, UsageQuery, WorkspaceRecord,
|
||||
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
};
|
||||
|
||||
fn test_workspace_id() -> WorkspaceId {
|
||||
@@ -145,6 +146,7 @@ async fn manages_published_agent_tool_reads() {
|
||||
version: version.version,
|
||||
published_at: ×tamp("2026-03-25T12:11:00Z"),
|
||||
published_by: Some("alice"),
|
||||
expected_state: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -170,6 +172,333 @@ async fn manages_published_agent_tool_reads() {
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn published_agent_snapshot_remains_immutable_after_draft_edit() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let operation = test_operation("op_agent_immutable_01", 1, OperationStatus::Draft);
|
||||
let agent = test_agent("agent_immutable_01", AgentStatus::Draft);
|
||||
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
||||
let published_binding = AgentOperationBinding {
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: operation.version,
|
||||
tool_name: "create_lead_immutable".to_owned(),
|
||||
tool_title: "Create lead".to_owned(),
|
||||
tool_description_override: Some("Published immutable binding".to_owned()),
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: operation.version,
|
||||
published_at: ×tamp("2026-03-25T12:10:00Z"),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.create_agent(CreateAgentRequest {
|
||||
agent: &agent,
|
||||
version: &version,
|
||||
bindings: std::slice::from_ref(&published_binding),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
agent_id: &agent.id,
|
||||
version: version.version,
|
||||
published_at: ×tamp("2026-03-25T12:11:00Z"),
|
||||
published_by: Some("alice"),
|
||||
expected_state: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let before = registry
|
||||
.get_published_agent_tools_by_slug("default", &agent.slug)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(before.len(), 1);
|
||||
assert_eq!(before[0].tool_name, published_binding.tool_name);
|
||||
|
||||
registry
|
||||
.save_agent_catalog_config(SaveAgentCatalogConfigRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
agent_id: &agent.id,
|
||||
agent_version: version.version,
|
||||
bindings: &[],
|
||||
tool_selection_policy: &Default::default(),
|
||||
expected_state: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("editing after publish must not mutate the published Agent Version");
|
||||
|
||||
let after = registry
|
||||
.get_published_agent_tools_by_slug("default", &agent.slug)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(after, before);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_agent_revision_rejects_mutation() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let operation = test_operation("op_agent_stale_01", 1, OperationStatus::Draft);
|
||||
let agent = test_agent("agent_stale_01", AgentStatus::Draft);
|
||||
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
||||
let binding = AgentOperationBinding {
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: operation.version,
|
||||
tool_name: "create_lead_stale".to_owned(),
|
||||
tool_title: "Create lead".to_owned(),
|
||||
tool_description_override: None,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: operation.version,
|
||||
published_at: ×tamp("2026-03-25T12:10:00Z"),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.create_agent(CreateAgentRequest {
|
||||
agent: &agent,
|
||||
version: &version,
|
||||
bindings: std::slice::from_ref(&binding),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
agent_id: &agent.id,
|
||||
version: version.version,
|
||||
published_at: ×tamp("2026-03-25T12:11:00Z"),
|
||||
published_by: Some("alice"),
|
||||
expected_state: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stale_save = registry
|
||||
.save_agent_catalog_config(SaveAgentCatalogConfigRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
agent_id: &agent.id,
|
||||
agent_version: version.version,
|
||||
bindings: &[],
|
||||
tool_selection_policy: &Default::default(),
|
||||
expected_state: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
stale_save.is_err(),
|
||||
"stale write against already-published Agent Version must be rejected"
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn published_agent_catalog_revision_is_durable_and_monotonic() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let operation = test_operation("op_agent_revision_01", 1, OperationStatus::Draft);
|
||||
let agent = test_agent("agent_revision_01", AgentStatus::Draft);
|
||||
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
||||
let binding = AgentOperationBinding {
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: operation.version,
|
||||
tool_name: "create_lead_revision".to_owned(),
|
||||
tool_title: "Create lead".to_owned(),
|
||||
tool_description_override: None,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: operation.version,
|
||||
published_at: ×tamp("2026-03-25T12:10:00Z"),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.create_agent(CreateAgentRequest {
|
||||
agent: &agent,
|
||||
version: &version,
|
||||
bindings: std::slice::from_ref(&binding),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
agent_id: &agent.id,
|
||||
version: version.version,
|
||||
published_at: ×tamp("2026-03-25T12:11:00Z"),
|
||||
published_by: Some("alice"),
|
||||
expected_state: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let first = registry
|
||||
.get_published_agent_catalog_by_slug("default", &agent.slug)
|
||||
.await
|
||||
.unwrap()
|
||||
.catalog_revision;
|
||||
|
||||
registry
|
||||
.unpublish_agent(
|
||||
&test_workspace_id(),
|
||||
&agent.id,
|
||||
×tamp("2026-03-25T12:12:00Z"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
agent_id: &agent.id,
|
||||
version: version.version,
|
||||
published_at: ×tamp("2026-03-25T12:13:00Z"),
|
||||
published_by: Some("alice"),
|
||||
expected_state: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let second = registry
|
||||
.get_published_agent_catalog_by_slug("default", &agent.slug)
|
||||
.await
|
||||
.unwrap()
|
||||
.catalog_revision;
|
||||
|
||||
assert_ne!(
|
||||
first, second,
|
||||
"unpublish/re-publish of the same Agent Version must invalidate stale catalog search results"
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn database_rejects_direct_published_agent_snapshot_mutation() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let operation = test_operation("op_agent_db_guard_01", 1, OperationStatus::Draft);
|
||||
let agent = test_agent("agent_db_guard_01", AgentStatus::Draft);
|
||||
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
||||
let binding = AgentOperationBinding {
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: operation.version,
|
||||
tool_name: "create_lead_db_guard".to_owned(),
|
||||
tool_title: "Create lead".to_owned(),
|
||||
tool_description_override: None,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: operation.version,
|
||||
published_at: ×tamp("2026-03-25T12:10:00Z"),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.create_agent(CreateAgentRequest {
|
||||
agent: &agent,
|
||||
version: &version,
|
||||
bindings: std::slice::from_ref(&binding),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
agent_id: &agent.id,
|
||||
version: version.version,
|
||||
published_at: ×tamp("2026-03-25T12:11:00Z"),
|
||||
published_by: Some("alice"),
|
||||
expected_state: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let policy_update = sqlx::query(
|
||||
"update agent_versions
|
||||
set tool_selection_policy_json = '{\"changed\":true}'::jsonb
|
||||
where agent_id = $1 and version = 1",
|
||||
)
|
||||
.bind(agent.id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await;
|
||||
assert!(
|
||||
policy_update.is_err(),
|
||||
"database trigger must reject direct Published Agent Version mutation"
|
||||
);
|
||||
|
||||
let binding_delete = sqlx::query(
|
||||
"delete from agent_operation_bindings
|
||||
where agent_id = $1 and agent_version = 1",
|
||||
)
|
||||
.bind(agent.id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await;
|
||||
assert!(
|
||||
binding_delete.is_err(),
|
||||
"database trigger must reject direct Published Agent binding deletion"
|
||||
);
|
||||
|
||||
let pointer_rewind = sqlx::query(
|
||||
"update published_agents
|
||||
set catalog_revision = catalog_revision
|
||||
where agent_id = $1",
|
||||
)
|
||||
.bind(agent.id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await;
|
||||
assert!(
|
||||
pointer_rewind.is_err(),
|
||||
"database trigger must reject non-increasing catalog revision"
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manages_operation_usage_and_agent_ref_reads() {
|
||||
let database = TestDatabase::new().await;
|
||||
@@ -225,6 +554,7 @@ async fn manages_operation_usage_and_agent_ref_reads() {
|
||||
version: version.version,
|
||||
published_at: ×tamp("2026-03-25T12:11:00Z"),
|
||||
published_by: Some("alice"),
|
||||
expected_state: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -243,6 +573,27 @@ async fn manages_operation_usage_and_agent_ref_reads() {
|
||||
.await,
|
||||
crank_registry::InvocationHistoryWriteOutcome::Recorded
|
||||
);
|
||||
let stored = registry
|
||||
.get_invocation_log(
|
||||
&test_workspace_id(),
|
||||
&crank_core::InvocationLogId::new("log_usage_ok"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("typed invocation history");
|
||||
assert_eq!(stored.log.operation_version, Some(1));
|
||||
assert_eq!(
|
||||
stored.log.execution_stage,
|
||||
Some(crank_core::ExecutionStage::Runtime)
|
||||
);
|
||||
assert_eq!(
|
||||
stored.log.retryability,
|
||||
Some(crank_core::Retryability::Never)
|
||||
);
|
||||
assert_eq!(
|
||||
stored.log.outcome_certainty,
|
||||
Some(crank_core::OutcomeCertainty::Certain)
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest {
|
||||
@@ -286,3 +637,214 @@ async fn manages_operation_usage_and_agent_ref_reads() {
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invocation_history_filters_cursor_and_usage_outcomes_are_bounded() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let operation = test_operation("op_history_01", 1, OperationStatus::Draft);
|
||||
let agent = test_agent("agent_history_01", AgentStatus::Draft);
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.create_agent(CreateAgentRequest {
|
||||
agent: &agent,
|
||||
version: &test_agent_version(&agent.id, 1, AgentStatus::Draft),
|
||||
bindings: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut success = test_invocation_log(
|
||||
"log_history_success",
|
||||
&operation.id,
|
||||
Some(agent.id.clone()),
|
||||
crank_core::InvocationStatus::Ok,
|
||||
50,
|
||||
"2026-03-25T12:00:00Z",
|
||||
);
|
||||
success.execution_error_code = None;
|
||||
success.message = "=formula must remain data".to_owned();
|
||||
let mut upstream = test_invocation_log(
|
||||
"log_history_upstream",
|
||||
&operation.id,
|
||||
Some(agent.id.clone()),
|
||||
crank_core::InvocationStatus::Error,
|
||||
150,
|
||||
"2026-03-25T12:01:00Z",
|
||||
);
|
||||
upstream.execution_error_code = Some(crank_core::ExecutionErrorCode::UpstreamTimeout);
|
||||
let mut client = test_invocation_log(
|
||||
"log_history_client",
|
||||
&operation.id,
|
||||
Some(agent.id.clone()),
|
||||
crank_core::InvocationStatus::Error,
|
||||
250,
|
||||
"2026-03-25T12:02:00Z",
|
||||
);
|
||||
client.execution_error_code = Some(crank_core::ExecutionErrorCode::InputSchemaInvalid);
|
||||
let mut schema = test_invocation_log(
|
||||
"log_history_schema",
|
||||
&operation.id,
|
||||
Some(agent.id.clone()),
|
||||
crank_core::InvocationStatus::Error,
|
||||
350,
|
||||
"2026-03-25T12:03:00Z",
|
||||
);
|
||||
schema.execution_error_code = Some(crank_core::ExecutionErrorCode::OutputSchemaInvalid);
|
||||
let mut crank = test_invocation_log(
|
||||
"log_history_crank",
|
||||
&operation.id,
|
||||
Some(agent.id.clone()),
|
||||
crank_core::InvocationStatus::Error,
|
||||
450,
|
||||
"2026-03-25T12:04:00Z",
|
||||
);
|
||||
crank.execution_error_code = Some(crank_core::ExecutionErrorCode::RuntimeInternal);
|
||||
|
||||
for log in [&success, &upstream, &client, &schema, &crank] {
|
||||
assert_eq!(
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log })
|
||||
.await,
|
||||
crank_registry::InvocationHistoryWriteOutcome::Recorded
|
||||
);
|
||||
}
|
||||
|
||||
let first_page = registry
|
||||
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
status: Some(crank_core::InvocationStatus::Error),
|
||||
outcome_group: None,
|
||||
search_text: None,
|
||||
source: None,
|
||||
operation_id: None,
|
||||
agent_id: None,
|
||||
created_after: Some("2026-03-25T12:00:00Z"),
|
||||
created_before: Some("2026-03-25T12:04:00Z"),
|
||||
cursor_created_at: None,
|
||||
cursor_id: None,
|
||||
limit: 2,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first_page.len(), 2);
|
||||
assert_eq!(first_page[0].log.id.as_str(), "log_history_schema");
|
||||
assert_eq!(first_page[1].log.id.as_str(), "log_history_client");
|
||||
|
||||
let second_page = registry
|
||||
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
status: Some(crank_core::InvocationStatus::Error),
|
||||
outcome_group: None,
|
||||
search_text: None,
|
||||
source: None,
|
||||
operation_id: None,
|
||||
agent_id: None,
|
||||
created_after: Some("2026-03-25T12:00:00Z"),
|
||||
created_before: Some("2026-03-25T12:04:00Z"),
|
||||
cursor_created_at: Some("2026-03-25T12:02:00Z"),
|
||||
cursor_id: Some(&crank_core::InvocationLogId::new("log_history_client")),
|
||||
limit: 2,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second_page.len(), 1);
|
||||
assert_eq!(second_page[0].log.id.as_str(), "log_history_upstream");
|
||||
|
||||
let upstream_only = registry
|
||||
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
status: None,
|
||||
outcome_group: Some(UsageOutcomeGroup::Upstream),
|
||||
search_text: None,
|
||||
source: None,
|
||||
operation_id: None,
|
||||
agent_id: None,
|
||||
created_after: Some("2026-03-25T12:00:00Z"),
|
||||
created_before: Some("2026-03-25T12:05:00Z"),
|
||||
cursor_created_at: None,
|
||||
cursor_id: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(upstream_only.len(), 1);
|
||||
assert_eq!(upstream_only[0].log.id.as_str(), "log_history_upstream");
|
||||
|
||||
let usage = registry
|
||||
.list_usage_outcomes(UsageQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
period: crank_core::UsagePeriod::Last24Hours,
|
||||
source: None,
|
||||
created_after: "2026-03-25T12:00:00Z",
|
||||
created_before: "2026-03-25T12:05:00Z",
|
||||
bucket: UsageBucket::Hour,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let by_group = usage
|
||||
.iter()
|
||||
.map(|item| (item.group, item.calls_total))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
assert_eq!(by_group.get(&UsageOutcomeGroup::Success), Some(&1));
|
||||
assert_eq!(by_group.get(&UsageOutcomeGroup::Upstream), Some(&1));
|
||||
assert_eq!(by_group.get(&UsageOutcomeGroup::Client), Some(&1));
|
||||
assert_eq!(by_group.get(&UsageOutcomeGroup::Schema), Some(&1));
|
||||
assert_eq!(by_group.get(&UsageOutcomeGroup::Crank), Some(&1));
|
||||
|
||||
let half_open = registry
|
||||
.summarize_usage(UsageQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
period: crank_core::UsagePeriod::Last24Hours,
|
||||
source: None,
|
||||
created_after: "2026-03-25T12:00:00Z",
|
||||
created_before: "2026-03-25T12:04:00Z",
|
||||
bucket: UsageBucket::Hour,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(half_open.rollup.calls_total, 4);
|
||||
|
||||
let retention = registry
|
||||
.delete_invocation_logs_before(timestamp("2026-03-25T12:02:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(retention.deleted_records, 2);
|
||||
assert_eq!(
|
||||
retention.status,
|
||||
crank_registry::InvocationRetentionStatus::Completed
|
||||
);
|
||||
assert_eq!(retention.policy.preserved_usage_window_days, 90);
|
||||
let retained = registry
|
||||
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
status: None,
|
||||
outcome_group: None,
|
||||
search_text: None,
|
||||
source: None,
|
||||
operation_id: None,
|
||||
agent_id: None,
|
||||
created_after: Some("2026-03-25T12:00:00Z"),
|
||||
created_before: Some("2026-03-25T12:05:00Z"),
|
||||
cursor_created_at: None,
|
||||
cursor_id: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(retained.len(), 3);
|
||||
assert_eq!(
|
||||
retained.last().unwrap().log.id.as_str(),
|
||||
"log_history_client"
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crank_core::{
|
||||
AgentId, AgentOperationBinding, AgentStatus, ApprovalRequest, ApprovalRequestId,
|
||||
ApprovalRequestStatus, OperationApprovalRiskLevel, OperationId, PlatformApiKey,
|
||||
PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, WorkspaceId,
|
||||
};
|
||||
use crank_registry::{
|
||||
CreateAgentRequest, CreateApprovalRequest, CreatePlatformApiKeyRequest, DecideApprovalRequest,
|
||||
FinishApprovalRequest,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::common::{TestDatabase, test_agent, test_agent_version, test_operation};
|
||||
|
||||
fn timestamp(value: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||
}
|
||||
|
||||
fn approval_request(id: &str, payload: Value) -> ApprovalRequest {
|
||||
ApprovalRequest {
|
||||
id: ApprovalRequestId::new(id),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
agent_id: AgentId::new("agent_approval_atomic"),
|
||||
operation_id: OperationId::new("operation_approval_atomic"),
|
||||
operation_version: 1,
|
||||
status: ApprovalRequestStatus::Pending,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
request_id: None,
|
||||
trace_id: None,
|
||||
request_payload: payload,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_key() -> PlatformApiKey {
|
||||
PlatformApiKey {
|
||||
id: PlatformApiKeyId::new("approval_atomic_key"),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
agent_id: Some(AgentId::new("agent_approval_atomic")),
|
||||
key_kind: PlatformApiKeyKind::Approval,
|
||||
name: "approval atomic 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(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_approval_graph(registry: &crank_registry::PostgresRegistry) {
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let operation = test_operation(
|
||||
"operation_approval_atomic",
|
||||
1,
|
||||
crank_core::OperationStatus::Published,
|
||||
);
|
||||
registry
|
||||
.create_operation(&workspace_id, &operation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let agent = test_agent("agent_approval_atomic", AgentStatus::Draft);
|
||||
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
||||
registry
|
||||
.create_agent(CreateAgentRequest {
|
||||
agent: &agent,
|
||||
version: &version,
|
||||
bindings: &[AgentOperationBinding {
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: 1,
|
||||
tool_name: "dangerous_write".to_owned(),
|
||||
tool_title: "Dangerous write".to_owned(),
|
||||
tool_description_override: None,
|
||||
enabled: true,
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let key = approval_key();
|
||||
registry
|
||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
||||
api_key: &key,
|
||||
secret_hash: "approval-key-secret-hash",
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_equal_scope_creates_one_pending_approval_without_losing_raw_execution_payload()
|
||||
{
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = Arc::new(database.registry().await);
|
||||
seed_approval_graph(®istry).await;
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for index in 0..32 {
|
||||
let registry = Arc::clone(®istry);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let mut approval = approval_request(
|
||||
&format!("approval_concurrent_{index}"),
|
||||
json!({
|
||||
"email": "customer@example.com",
|
||||
"token": "SECRET_APPROVAL_CANARY",
|
||||
"_crank_confirmation_token": format!("ct_{index}")
|
||||
}),
|
||||
);
|
||||
approval.created_at += time::Duration::milliseconds(index);
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}));
|
||||
}
|
||||
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for task in tasks {
|
||||
ids.insert(task.await.unwrap().approval.id.as_str().to_owned());
|
||||
}
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
1,
|
||||
"all callers must receive one canonical pending approval"
|
||||
);
|
||||
|
||||
let pending = registry
|
||||
.list_pending_approval_requests_for_agent(
|
||||
&WorkspaceId::new("ws_default"),
|
||||
&AgentId::new("agent_approval_atomic"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(
|
||||
pending[0].approval.request_payload["token"], "SECRET_APPROVAL_CANARY",
|
||||
"registry keeps the raw payload for the eventual approved execution"
|
||||
);
|
||||
assert!(
|
||||
pending[0].approval.request_payload["_crank_confirmation_token"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.starts_with("ct_")),
|
||||
"only projections may redact control data; execution input remains intact"
|
||||
);
|
||||
let safe_preview =
|
||||
crank_core::sanitize_invocation_preview(&pending[0].approval.request_payload);
|
||||
let preview = safe_preview.to_string();
|
||||
assert!(!preview.contains("SECRET_APPROVAL_CANARY"));
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nested_business_control_like_fields_are_part_of_approval_fingerprint() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
seed_approval_graph(®istry).await;
|
||||
|
||||
let first = approval_request(
|
||||
"approval_nested_control_a",
|
||||
json!({
|
||||
"line": {
|
||||
"_crank_approval_id": "business-a"
|
||||
},
|
||||
"_crank_confirmation_token": "transport-a"
|
||||
}),
|
||||
);
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest { approval: &first })
|
||||
.await
|
||||
.unwrap();
|
||||
let second = approval_request(
|
||||
"approval_nested_control_b",
|
||||
json!({
|
||||
"line": {
|
||||
"_crank_approval_id": "business-b"
|
||||
},
|
||||
"_crank_confirmation_token": "transport-b"
|
||||
}),
|
||||
);
|
||||
let created_second = registry
|
||||
.create_approval_request(CreateApprovalRequest { approval: &second })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(created_second.approval.id, second.id);
|
||||
let pending = registry
|
||||
.list_pending_approval_requests_for_agent(
|
||||
&WorkspaceId::new("ws_default"),
|
||||
&AgentId::new("agent_approval_atomic"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending.len(), 2);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decision_claim_finish_and_replay_are_single_winner_transitions() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = Arc::new(database.registry().await);
|
||||
seed_approval_graph(®istry).await;
|
||||
let approval = approval_request(
|
||||
"approval_single_winner",
|
||||
json!({"email":"lead@example.com"}),
|
||||
);
|
||||
let created = registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.approval.status, ApprovalRequestStatus::Pending);
|
||||
|
||||
let mut decisions = Vec::new();
|
||||
for index in 0..16 {
|
||||
let registry = Arc::clone(®istry);
|
||||
let operation_id = approval.operation_id.clone();
|
||||
let operation_version = approval.operation_version;
|
||||
let request_payload = approval.request_payload.clone();
|
||||
decisions.push(tokio::spawn(async move {
|
||||
let key_id = PlatformApiKeyId::new("approval_atomic_key");
|
||||
registry
|
||||
.decide_approval_request(DecideApprovalRequest {
|
||||
workspace_id: &WorkspaceId::new("ws_default"),
|
||||
agent_id: &AgentId::new("agent_approval_atomic"),
|
||||
approval_id: &ApprovalRequestId::new("approval_single_winner"),
|
||||
operation_id: &operation_id,
|
||||
operation_version,
|
||||
request_payload: &request_payload,
|
||||
status: if index % 2 == 0 {
|
||||
ApprovalRequestStatus::Approved
|
||||
} else {
|
||||
ApprovalRequestStatus::Denied
|
||||
},
|
||||
decided_at: timestamp("2026-03-25T12:02:00Z"),
|
||||
decided_by_key_id: Some(&key_id),
|
||||
response_payload: Some(json!({ "decision": index })),
|
||||
decision_note: Some("race decision"),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}));
|
||||
}
|
||||
let mut winners = 0;
|
||||
for decision in decisions {
|
||||
if decision.await.unwrap().is_some() {
|
||||
winners += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(winners, 1);
|
||||
|
||||
let mut claims = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let registry = Arc::clone(®istry);
|
||||
claims.push(tokio::spawn(async move {
|
||||
registry
|
||||
.claim_approval_request(
|
||||
&WorkspaceId::new("ws_default"),
|
||||
&AgentId::new("agent_approval_atomic"),
|
||||
&ApprovalRequestId::new("approval_single_winner"),
|
||||
timestamp("2026-03-25T12:02:01Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}));
|
||||
}
|
||||
let mut claim_winners = 0;
|
||||
for claim in claims {
|
||||
if claim.await.unwrap().is_some() {
|
||||
claim_winners += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(claim_winners, 1);
|
||||
|
||||
let first_finish = registry
|
||||
.finish_approval_request(FinishApprovalRequest {
|
||||
workspace_id: &WorkspaceId::new("ws_default"),
|
||||
agent_id: &AgentId::new("agent_approval_atomic"),
|
||||
approval_id: &ApprovalRequestId::new("approval_single_winner"),
|
||||
status: ApprovalRequestStatus::Completed,
|
||||
response_payload: Some(json!({"ok":true})),
|
||||
decision_note: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(first_finish.is_some());
|
||||
let replay_finish = registry
|
||||
.finish_approval_request(FinishApprovalRequest {
|
||||
workspace_id: &WorkspaceId::new("ws_default"),
|
||||
agent_id: &AgentId::new("agent_approval_atomic"),
|
||||
approval_id: &ApprovalRequestId::new("approval_single_winner"),
|
||||
status: ApprovalRequestStatus::Completed,
|
||||
response_payload: Some(json!({"ok":"replay"})),
|
||||
decision_note: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(replay_finish.is_none());
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
@@ -21,11 +21,12 @@ use crank_registry::{
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
|
||||
DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry,
|
||||
PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId,
|
||||
YamlImportJobStatus,
|
||||
};
|
||||
|
||||
fn test_workspace_id() -> WorkspaceId {
|
||||
pub(super) fn test_workspace_id() -> WorkspaceId {
|
||||
WorkspaceId::new("ws_default")
|
||||
}
|
||||
|
||||
@@ -209,7 +210,9 @@ pub(super) fn test_invocation_log(
|
||||
id: crank_core::InvocationLogId::new(id),
|
||||
workspace_id: test_workspace_id(),
|
||||
agent_id,
|
||||
platform_api_key_id: None,
|
||||
operation_id: operation_id.clone(),
|
||||
operation_version: Some(1),
|
||||
source: crank_core::InvocationSource::AgentToolCall,
|
||||
level: crank_core::InvocationLevel::Info,
|
||||
status,
|
||||
@@ -220,6 +223,11 @@ pub(super) fn test_invocation_log(
|
||||
status_code: Some(200),
|
||||
duration_ms,
|
||||
error_kind: None,
|
||||
execution_stage: Some(crank_core::ExecutionStage::Runtime),
|
||||
execution_error_code: (status == crank_core::InvocationStatus::Error)
|
||||
.then_some(crank_core::ExecutionErrorCode::RuntimeInternal),
|
||||
retryability: Some(crank_core::Retryability::Never),
|
||||
outcome_certainty: Some(crank_core::OutcomeCertainty::Certain),
|
||||
request_preview: json!({"input":"value"}),
|
||||
response_preview: json!({"ok":true}),
|
||||
created_at: timestamp(created_at),
|
||||
@@ -268,6 +276,18 @@ impl TestDatabase {
|
||||
PostgresRegistry::connect(&database_url).await.unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn raw_pool(&self) -> PgPool {
|
||||
let database_url = format!(
|
||||
"{}?options=-csearch_path%3D{}",
|
||||
self.database_url, self.schema
|
||||
);
|
||||
PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn cleanup(&self) {
|
||||
self.admin_pool
|
||||
.execute(sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
use super::common::*;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crank_core::{
|
||||
PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
|
||||
PlatformApiKeyStatus, Secret, SecretId, SecretKind, SecretStatus, Workspace, WorkspaceId,
|
||||
WorkspaceStatus,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sqlx::{PgPool, Row};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crank_registry::{
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateWorkspaceRequest,
|
||||
MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, RegistryError,
|
||||
};
|
||||
|
||||
fn timestamp(value: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||
}
|
||||
|
||||
fn workspace() -> Workspace {
|
||||
Workspace {
|
||||
id: WorkspaceId::new("ws_credential_touch"),
|
||||
slug: "credential-touch".to_owned(),
|
||||
display_name: "Credential Touch".to_owned(),
|
||||
status: WorkspaceStatus::Active,
|
||||
settings: json!({}),
|
||||
created_at: timestamp("2026-08-24T12:00:00Z"),
|
||||
updated_at: timestamp("2026-08-24T12:00:00Z"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_row_lock(inspector: &PgPool, holder_pid: i32, query_marker: &str) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
|
||||
loop {
|
||||
let blocked: i64 = sqlx::query_scalar(
|
||||
"select count(*)::bigint
|
||||
from pg_stat_activity activity
|
||||
where $1 = any(pg_blocking_pids(activity.pid))
|
||||
and activity.wait_event_type = 'Lock'
|
||||
and activity.query like '%' || $2 || '%'",
|
||||
)
|
||||
.bind(holder_pid)
|
||||
.bind(query_marker)
|
||||
.fetch_one(inspector)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if blocked > 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
let diagnostics = sqlx::query(
|
||||
"select pid, state, wait_event_type, wait_event, query
|
||||
from pg_stat_activity
|
||||
where $1 = any(pg_blocking_pids(pid))
|
||||
or pid = $1",
|
||||
)
|
||||
.bind(holder_pid)
|
||||
.fetch_all(inspector)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
format!(
|
||||
"pid={}, state={}, wait_event_type={:?}, wait_event={:?}, query={}",
|
||||
row.get::<i32, _>("pid"),
|
||||
row.get::<String, _>("state"),
|
||||
row.get::<Option<String>, _>("wait_event_type"),
|
||||
row.get::<Option<String>, _>("wait_event"),
|
||||
row.get::<String, _>("query"),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
panic!(
|
||||
"touch query did not block on backend {holder_pid} within 5 seconds; \
|
||||
pg_stat_activity: {diagnostics:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn touch_platform_api_key_rejects_revoke_that_won_row_lock() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let workspace = workspace();
|
||||
let key = PlatformApiKey {
|
||||
id: PlatformApiKeyId::new("key_touch_race"),
|
||||
workspace_id: workspace.id.clone(),
|
||||
agent_id: None,
|
||||
key_kind: PlatformApiKeyKind::McpClient,
|
||||
name: "Race key".to_owned(),
|
||||
prefix: "crk_live".to_owned(),
|
||||
scopes: vec![PlatformApiKeyScope::Read],
|
||||
status: PlatformApiKeyStatus::Active,
|
||||
created_at: timestamp("2026-08-24T12:00:00Z"),
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
allowed_origins: Vec::new(),
|
||||
};
|
||||
|
||||
registry
|
||||
.create_workspace(CreateWorkspaceRequest {
|
||||
workspace: &workspace,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
||||
api_key: &key,
|
||||
secret_hash: "touch-race-secret-hash",
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let lock_pool = database.raw_pool().await;
|
||||
let inspector = database.raw_pool().await;
|
||||
let mut lock_tx = lock_pool.begin().await.unwrap();
|
||||
let holder_pid: i32 = sqlx::query_scalar("select pg_backend_pid()")
|
||||
.fetch_one(&mut *lock_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"select id
|
||||
from platform_api_keys
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(workspace.id.as_str())
|
||||
.bind(key.id.as_str())
|
||||
.execute(&mut *lock_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let touch_registry = registry.clone();
|
||||
let touch_workspace_id = workspace.id.clone();
|
||||
let touch_key_id = key.id.clone();
|
||||
let touch = tokio::spawn(async move {
|
||||
touch_registry
|
||||
.touch_platform_api_key(
|
||||
&touch_workspace_id,
|
||||
&touch_key_id,
|
||||
×tamp("2026-08-24T12:05:00Z"),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
wait_for_row_lock(&inspector, holder_pid, "from platform_api_keys").await;
|
||||
|
||||
sqlx::query(
|
||||
"update platform_api_keys
|
||||
set status = 'revoked'
|
||||
where workspace_id = $1 and id = $2",
|
||||
)
|
||||
.bind(workspace.id.as_str())
|
||||
.bind(key.id.as_str())
|
||||
.execute(&mut *lock_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
lock_tx.commit().await.unwrap();
|
||||
|
||||
let touch_result = touch.await.unwrap();
|
||||
assert!(matches!(
|
||||
touch_result,
|
||||
Err(RegistryError::PlatformApiKeyInactive { key_id }) if key_id == key.id.as_str()
|
||||
));
|
||||
let last_used_at: Option<OffsetDateTime> = sqlx::query_scalar(
|
||||
"select last_used_at
|
||||
from platform_api_keys
|
||||
where workspace_id = $1 and id = $2",
|
||||
)
|
||||
.bind(workspace.id.as_str())
|
||||
.bind(key.id.as_str())
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(last_used_at, None);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn touch_secret_rejects_disable_that_won_row_lock() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let workspace = workspace();
|
||||
let secret = Secret {
|
||||
id: SecretId::new("secret_touch_race"),
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Race secret".to_owned(),
|
||||
kind: SecretKind::Token,
|
||||
status: SecretStatus::Active,
|
||||
current_version: 1,
|
||||
created_at: timestamp("2026-08-24T12:00:00Z"),
|
||||
updated_at: timestamp("2026-08-24T12:00:00Z"),
|
||||
last_used_at: None,
|
||||
};
|
||||
|
||||
registry
|
||||
.create_workspace(CreateWorkspaceRequest {
|
||||
workspace: &workspace,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: ×tamp("2026-08-24T12:00:00Z"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.create_secret(CreateSecretRequest {
|
||||
secret: &secret,
|
||||
ciphertext: "touch-race-ciphertext",
|
||||
key_version: "test-key-v1",
|
||||
master_key_epoch: 1,
|
||||
created_by: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let lock_pool = database.raw_pool().await;
|
||||
let inspector = database.raw_pool().await;
|
||||
let mut lock_tx = lock_pool.begin().await.unwrap();
|
||||
let holder_pid: i32 = sqlx::query_scalar("select pg_backend_pid()")
|
||||
.fetch_one(&mut *lock_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"select id
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(workspace.id.as_str())
|
||||
.bind(secret.id.as_str())
|
||||
.execute(&mut *lock_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let touch_registry = registry.clone();
|
||||
let touch_workspace_id = workspace.id.clone();
|
||||
let touch_secret_id = secret.id.clone();
|
||||
let touch = tokio::spawn(async move {
|
||||
touch_registry
|
||||
.touch_secret(
|
||||
&touch_workspace_id,
|
||||
&touch_secret_id,
|
||||
×tamp("2026-08-24T12:05:00Z"),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
wait_for_row_lock(&inspector, holder_pid, "from secrets").await;
|
||||
|
||||
sqlx::query(
|
||||
"update secrets
|
||||
set status = 'disabled'
|
||||
where workspace_id = $1 and id = $2",
|
||||
)
|
||||
.bind(workspace.id.as_str())
|
||||
.bind(secret.id.as_str())
|
||||
.execute(&mut *lock_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
lock_tx.commit().await.unwrap();
|
||||
|
||||
let touch_result = touch.await.unwrap();
|
||||
assert!(matches!(
|
||||
touch_result,
|
||||
Err(RegistryError::SecretInactive { secret_id }) if secret_id == secret.id.as_str()
|
||||
));
|
||||
let last_used_at: Option<OffsetDateTime> = sqlx::query_scalar(
|
||||
"select last_used_at
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2",
|
||||
)
|
||||
.bind(workspace.id.as_str())
|
||||
.bind(secret.id.as_str())
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(last_used_at, None);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
use crank_core::{Secret, SecretId, SecretKind, SecretStatus, WorkspaceId};
|
||||
use crank_registry::{
|
||||
CreateSecretRequest, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, RegistryError,
|
||||
RotateSecretRequest,
|
||||
};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::common::TestDatabase;
|
||||
|
||||
fn timestamp(value: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registers_and_verifies_active_master_key_identity() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
let registered = registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let verified = registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(registered.epoch, 1);
|
||||
assert_eq!(registered.status, "active");
|
||||
assert_eq!(registered, verified);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_different_active_master_key_identity() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RegistryError::MasterKeyIdentityMismatch { epoch: 1 }
|
||||
));
|
||||
assert!(!error.to_string().contains("fedcba"));
|
||||
assert!(!error.to_string().contains("012345"));
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn master_key_rotation_is_resumable_and_promotes_atomically() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
let later = timestamp("2026-08-21T00:01:00Z");
|
||||
let current_fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: current_fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
insert_secret_version(®istry, "secret_a", "cipher-a", &now).await;
|
||||
insert_secret_version(®istry, "secret_b", "cipher-b", &now).await;
|
||||
|
||||
let snapshot = registry
|
||||
.list_secret_versions_for_master_key_epoch(1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.len(), 2);
|
||||
|
||||
let preflight_status = registry.master_key_rotation_status().await.unwrap();
|
||||
assert_eq!(preflight_status.active_identity.unwrap().epoch, 1);
|
||||
assert!(preflight_status.rotations.is_empty());
|
||||
assert!(
|
||||
registry
|
||||
.list_secret_versions_for_master_key_epoch(1)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|version| version.target_ciphertext.is_none())
|
||||
);
|
||||
|
||||
let rotation = registry
|
||||
.begin_master_key_rotation(1, 2, target_fingerprint, Some("offline-backup-ref"), &now)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rotation.state, "running");
|
||||
assert_eq!(rotation.total_secret_versions, 2);
|
||||
|
||||
registry
|
||||
.stage_master_key_rotation_ciphertext(
|
||||
&rotation.id,
|
||||
&SecretId::new("secret_a"),
|
||||
1,
|
||||
1,
|
||||
"target-cipher-a",
|
||||
"v2",
|
||||
2,
|
||||
&later,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let resumed = registry
|
||||
.begin_master_key_rotation(1, 2, target_fingerprint, Some("offline-backup-ref"), &later)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resumed.processed_secret_versions, 1);
|
||||
|
||||
registry
|
||||
.stage_master_key_rotation_ciphertext(
|
||||
&rotation.id,
|
||||
&SecretId::new("secret_b"),
|
||||
1,
|
||||
1,
|
||||
"target-cipher-b",
|
||||
"v2",
|
||||
2,
|
||||
&later,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let ready = registry
|
||||
.finish_master_key_rotation_batches(&rotation.id, &later)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ready.state, "verifying");
|
||||
registry
|
||||
.verify_master_key_rotation(&rotation.id, 2, &later)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.promote_master_key_rotation(
|
||||
&rotation.id,
|
||||
MasterKeyIdentityCandidate {
|
||||
epoch: 2,
|
||||
fingerprint: target_fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &later,
|
||||
},
|
||||
&later,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = registry.master_key_rotation_status().await.unwrap();
|
||||
let active = status.active_identity.unwrap();
|
||||
assert_eq!(active.epoch, 2);
|
||||
assert_eq!(active.fingerprint, target_fingerprint);
|
||||
assert_eq!(status.rotations[0].state, "promoted");
|
||||
assert!(
|
||||
registry
|
||||
.list_secret_versions_for_master_key_epoch(1)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let promoted = registry
|
||||
.list_secret_versions_for_master_key_epoch(2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(promoted.len(), 2);
|
||||
assert!(
|
||||
promoted
|
||||
.iter()
|
||||
.all(|version| version.target_ciphertext.is_none())
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn abort_preserves_current_epoch_and_clears_target_ciphertext() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
insert_secret_version(®istry, "secret_abort", "cipher-a", &now).await;
|
||||
let rotation = registry
|
||||
.begin_master_key_rotation(
|
||||
1,
|
||||
2,
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
||||
None,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.stage_master_key_rotation_ciphertext(
|
||||
&rotation.id,
|
||||
&SecretId::new("secret_abort"),
|
||||
1,
|
||||
1,
|
||||
"target-cipher-a",
|
||||
"v2",
|
||||
2,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
registry
|
||||
.abort_master_key_rotation(&rotation.id, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = registry.master_key_rotation_status().await.unwrap();
|
||||
assert_eq!(status.active_identity.unwrap().epoch, 1);
|
||||
assert_eq!(status.rotations[0].state, "aborted");
|
||||
let versions = registry
|
||||
.list_secret_versions_for_master_key_epoch(1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(versions.len(), 1);
|
||||
assert!(versions[0].target_ciphertext.is_none());
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aborted_rotation_can_be_rerun_for_same_source_epoch() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
insert_secret_version(®istry, "secret_retry", "cipher-a", &now).await;
|
||||
let rotation = registry
|
||||
.begin_master_key_rotation(1, 2, target_fingerprint, None, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.stage_master_key_rotation_ciphertext(
|
||||
&rotation.id,
|
||||
&SecretId::new("secret_retry"),
|
||||
1,
|
||||
1,
|
||||
"target-cipher-a",
|
||||
"v2",
|
||||
2,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.abort_master_key_rotation(&rotation.id, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rerun = registry
|
||||
.begin_master_key_rotation(1, 2, target_fingerprint, Some("second-attempt"), &now)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rerun.id, rotation.id);
|
||||
assert_eq!(rerun.state, "running");
|
||||
assert_eq!(rerun.processed_secret_versions, 0);
|
||||
assert_eq!(rerun.backup_ref.as_deref(), Some("second-attempt"));
|
||||
assert!(
|
||||
registry
|
||||
.list_secret_versions_for_master_key_epoch(1)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|version| version.target_ciphertext.is_none())
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_rotation_rejects_new_secret_writes() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
insert_secret_version(®istry, "existing_secret", "cipher-a", &now).await;
|
||||
registry
|
||||
.begin_master_key_rotation(
|
||||
1,
|
||||
2,
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
||||
None,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let create_error = registry
|
||||
.create_secret(CreateSecretRequest {
|
||||
secret: &test_secret("new_secret", &now),
|
||||
ciphertext: "cipher-new",
|
||||
key_version: "v2",
|
||||
master_key_epoch: 1,
|
||||
created_by: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
create_error,
|
||||
RegistryError::MasterKeyRotationInProgress
|
||||
));
|
||||
|
||||
let rotate_error = registry
|
||||
.rotate_secret(RotateSecretRequest {
|
||||
workspace_id: &WorkspaceId::new("ws_default"),
|
||||
secret_id: &SecretId::new("existing_secret"),
|
||||
ciphertext: "cipher-rotated",
|
||||
key_version: "v2",
|
||||
master_key_epoch: 1,
|
||||
created_at: &now,
|
||||
updated_at: &now,
|
||||
created_by: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
rotate_error,
|
||||
RegistryError::MasterKeyRotationInProgress
|
||||
));
|
||||
|
||||
let delete_error = registry
|
||||
.delete_secret(
|
||||
&WorkspaceId::new("ws_default"),
|
||||
&SecretId::new("existing_secret"),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
delete_error,
|
||||
RegistryError::MasterKeyRotationInProgress
|
||||
));
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_source_epoch_writer_is_rejected_after_promotion() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
let source_fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: source_fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
insert_secret_version(®istry, "promoted_secret", "cipher-a", &now).await;
|
||||
let rotation = registry
|
||||
.begin_master_key_rotation(1, 2, target_fingerprint, None, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.stage_master_key_rotation_ciphertext(
|
||||
&rotation.id,
|
||||
&SecretId::new("promoted_secret"),
|
||||
1,
|
||||
1,
|
||||
"target-cipher-a",
|
||||
"v2",
|
||||
2,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.finish_master_key_rotation_batches(&rotation.id, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.verify_master_key_rotation(&rotation.id, 1, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.promote_master_key_rotation(
|
||||
&rotation.id,
|
||||
MasterKeyIdentityCandidate {
|
||||
epoch: 2,
|
||||
fingerprint: target_fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
},
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let create_error = registry
|
||||
.create_secret(CreateSecretRequest {
|
||||
secret: &test_secret("stale_new_secret", &now),
|
||||
ciphertext: "cipher-new",
|
||||
key_version: "v2",
|
||||
master_key_epoch: 1,
|
||||
created_by: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
create_error,
|
||||
RegistryError::MasterKeyIdentityMismatch { epoch: 2 }
|
||||
));
|
||||
|
||||
let rotate_error = registry
|
||||
.rotate_secret(RotateSecretRequest {
|
||||
workspace_id: &WorkspaceId::new("ws_default"),
|
||||
secret_id: &SecretId::new("promoted_secret"),
|
||||
ciphertext: "cipher-rotated",
|
||||
key_version: "v2",
|
||||
master_key_epoch: 1,
|
||||
created_at: &now,
|
||||
updated_at: &now,
|
||||
created_by: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
rotate_error,
|
||||
RegistryError::MasterKeyIdentityMismatch { epoch: 2 }
|
||||
));
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
async fn insert_secret_version(
|
||||
registry: &crank_registry::PostgresRegistry,
|
||||
id: &str,
|
||||
ciphertext: &str,
|
||||
now: &OffsetDateTime,
|
||||
) {
|
||||
registry
|
||||
.create_secret(CreateSecretRequest {
|
||||
secret: &test_secret(id, now),
|
||||
ciphertext,
|
||||
key_version: "v2",
|
||||
master_key_epoch: 1,
|
||||
created_by: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn test_secret(id: &str, now: &OffsetDateTime) -> Secret {
|
||||
Secret {
|
||||
id: SecretId::new(id),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
name: id.to_owned(),
|
||||
kind: SecretKind::Token,
|
||||
status: SecretStatus::Active,
|
||||
current_version: 1,
|
||||
created_at: *now,
|
||||
updated_at: *now,
|
||||
last_used_at: None,
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,62 @@
|
||||
use crank_registry::{MigrationAuthority, MigrationPreflight, PostgresRegistry};
|
||||
use sqlx::Row;
|
||||
|
||||
mod rollback;
|
||||
|
||||
static EVENT_TRIGGER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
fn legacy_preservation_row_expr(table: &str) -> &'static str {
|
||||
match table {
|
||||
"invocation_logs" => {
|
||||
"to_jsonb(t) - array['trace_id','operation_version','execution_stage','execution_error_code','retryability','outcome_certainty','platform_api_key_id']"
|
||||
}
|
||||
"operation_versions" => {
|
||||
"to_jsonb(t) - array['name','display_name','category','protocol','security_level','snapshot_provenance','snapshot_observed_at','published_at','published_by']"
|
||||
}
|
||||
"approval_requests" => "to_jsonb(t) - array['request_id','trace_id']",
|
||||
"agents" => "to_jsonb(t) - array['catalog_revision']",
|
||||
_ => "to_jsonb(t)",
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn controlled_authority_is_versioned_and_safe_under_concurrent_apply() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_core_migration").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
|
||||
let (first, second) = tokio::join!(
|
||||
MigrationAuthority::apply(&pool),
|
||||
MigrationAuthority::apply(&pool),
|
||||
);
|
||||
first.expect("first controlled runner must apply the sequence");
|
||||
second.expect("second controlled runner must observe the applied sequence");
|
||||
|
||||
let first = PostgresRegistry::connect(&database_url)
|
||||
.await
|
||||
.expect("service startup must verify the migrated schema");
|
||||
|
||||
let rows =
|
||||
sqlx::query("select version, name, checksum from __crank_migrations order by version")
|
||||
.fetch_all(first.pool())
|
||||
.await
|
||||
.expect("migration ledger must be readable");
|
||||
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0].get::<i64, _>("version"), 1);
|
||||
assert_eq!(rows[0].get::<String, _>("name"), "community-baseline-v1");
|
||||
assert_eq!(
|
||||
rows[0].get::<String, _>("checksum"),
|
||||
"crank-community-baseline-v1"
|
||||
);
|
||||
assert_eq!(rows[1].get::<i64, _>("version"), 2);
|
||||
assert_eq!(rows[1].get::<String, _>("name"), "legacy-consolidation-v2");
|
||||
assert_eq!(rows[1].get::<String, _>("checksum").len(), 64);
|
||||
assert_eq!(rows[2].get::<i64, _>("version"), 3);
|
||||
assert_eq!(
|
||||
rows[2].get::<String, _>("name"),
|
||||
"request-trace-identity-v3"
|
||||
);
|
||||
assert_eq!(rows[2].get::<String, _>("checksum").len(), 64);
|
||||
|
||||
let expected = [
|
||||
(1, "community-baseline-v1"),
|
||||
(2, "legacy-consolidation-v2"),
|
||||
(3, "request-trace-identity-v3"),
|
||||
(4, "operation-lifecycle-v4"),
|
||||
(5, "execution-outcome-v5"),
|
||||
(6, "platform-key-name-reuse-v6"),
|
||||
(7, "master-key-identity-v7"),
|
||||
(8, "admin-auth-lifecycle-v8"),
|
||||
(9, "agent-catalog-lifecycle-v9"),
|
||||
(10, "approval-side-effects-v10"),
|
||||
(11, "onboarding-product-events-v11"),
|
||||
];
|
||||
assert_eq!(rows.len(), expected.len());
|
||||
for (row, (version, name)) in rows.iter().zip(expected) {
|
||||
assert_eq!(row.get::<i64, _>("version"), version);
|
||||
assert_eq!(row.get::<String, _>("name"), name);
|
||||
let checksum = row.get::<String, _>("checksum");
|
||||
assert!(checksum == "crank-community-baseline-v1" || checksum.len() == 64);
|
||||
}
|
||||
let approval_columns = sqlx::query(
|
||||
"select column_name
|
||||
from information_schema.columns
|
||||
@@ -53,22 +68,41 @@ async fn controlled_authority_is_versioned_and_safe_under_concurrent_apply() {
|
||||
.await
|
||||
.expect("approval schema must be readable");
|
||||
assert_eq!(approval_columns.len(), 3);
|
||||
|
||||
let onboarding_relations = sqlx::query(
|
||||
"select table_name
|
||||
from information_schema.tables
|
||||
where table_schema = current_schema()
|
||||
and table_name in ('product_events', 'product_event_daily_rollups', 'onboarding_selections')
|
||||
order by table_name",
|
||||
)
|
||||
.fetch_all(first.pool())
|
||||
.await
|
||||
.expect("V11 onboarding relations must be readable");
|
||||
assert_eq!(onboarding_relations.len(), 3);
|
||||
let key_scope_column: bool = sqlx::query_scalar(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'invocation_logs'
|
||||
and column_name = 'platform_api_key_id'
|
||||
)",
|
||||
)
|
||||
.fetch_one(first.pool())
|
||||
.await
|
||||
.expect("V11 key provenance column must be readable");
|
||||
assert!(key_scope_column);
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(first.pool()).await.unwrap(),
|
||||
MigrationPreflight::Current { version: 3 }
|
||||
MigrationPreflight::Current { version: 11 }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_connect_is_read_only_on_fresh_database() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_read_only_startup").await;
|
||||
|
||||
let error = PostgresRegistry::connect(&database_url)
|
||||
.await
|
||||
.expect_err("fresh schema must require the controlled migration command");
|
||||
assert!(error.to_string().contains("schema_missing"));
|
||||
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
let ledger = sqlx::query("select to_regclass('__crank_migrations')::text as name")
|
||||
.fetch_one(&pool)
|
||||
@@ -81,7 +115,6 @@ async fn service_connect_is_read_only_on_fresh_database() {
|
||||
"startup compatibility check must not create DDL"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_checksum_fails_closed_without_repair() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_changed_checksum").await;
|
||||
@@ -91,7 +124,6 @@ async fn changed_checksum_fails_closed_without_repair() {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = MigrationAuthority::apply(&pool)
|
||||
.await
|
||||
.expect_err("published checksum mismatch must fail closed");
|
||||
@@ -106,12 +138,12 @@ async fn changed_checksum_fails_closed_without_repair() {
|
||||
"authority must not rewrite corrupt history"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_legacy_core").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
remove_v4_schema(&pool).await;
|
||||
sqlx::query(
|
||||
"insert into workspaces (id, slug, display_name, status, settings_json, created_at, updated_at)
|
||||
values ('ws_preserved', 'preserved', 'Preserved', 'active', '{}'::jsonb, now(), now())",
|
||||
@@ -164,11 +196,7 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
||||
];
|
||||
let mut before = Vec::new();
|
||||
for table in tables {
|
||||
let row = if table == "invocation_logs" {
|
||||
"to_jsonb(t) - 'trace_id'"
|
||||
} else {
|
||||
"to_jsonb(t)"
|
||||
};
|
||||
let row = legacy_preservation_row_expr(table);
|
||||
let sql = format!("select jsonb_agg({row} order by {row}::text)::text from {table} t");
|
||||
before.push(
|
||||
sqlx::query_scalar::<_, Option<String>>(sqlx::AssertSqlSafe(sql))
|
||||
@@ -184,12 +212,11 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 1,
|
||||
target: 3,
|
||||
target: 11,
|
||||
}
|
||||
);
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
@@ -201,11 +228,7 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
||||
assert_eq!(display_name, "Preserved");
|
||||
let mut after = Vec::new();
|
||||
for table in tables {
|
||||
let row = if table == "invocation_logs" {
|
||||
"to_jsonb(t) - 'trace_id'"
|
||||
} else {
|
||||
"to_jsonb(t)"
|
||||
};
|
||||
let row = legacy_preservation_row_expr(table);
|
||||
let sql = format!("select jsonb_agg({row} order by {row}::text)::text from {table} t");
|
||||
after.push(
|
||||
sqlx::query_scalar::<_, Option<String>>(sqlx::AssertSqlSafe(sql))
|
||||
@@ -216,7 +239,6 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
||||
}
|
||||
assert_eq!(before, after, "brownfield rows must remain byte-equivalent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_mcp_sessions_survive_consolidation() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_legacy_mcp").await;
|
||||
@@ -232,12 +254,12 @@ async fn legacy_mcp_sessions_survive_consolidation() {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v4_schema(&pool).await;
|
||||
remove_v3_schema(&pool).await;
|
||||
sqlx::query("drop table __crank_migrations, __crank_migration_legacy_audit")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let count = sqlx::query("select count(*)::bigint as count from mcp_transport_sessions where id = 'session_preserved'")
|
||||
.fetch_one(&pool)
|
||||
@@ -246,7 +268,6 @@ async fn legacy_mcp_sessions_survive_consolidation() {
|
||||
.get::<i64, _>("count");
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_apply_does_not_rewrite_audit_timestamps() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_repeat_apply").await;
|
||||
@@ -259,7 +280,6 @@ async fn repeated_apply_does_not_rewrite_audit_timestamps() {
|
||||
.into_iter()
|
||||
.map(|row| row.get::<time::OffsetDateTime, _>("applied_at"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let after = sqlx::query("select applied_at from __crank_migrations order by version")
|
||||
.fetch_all(&pool)
|
||||
@@ -270,13 +290,12 @@ async fn repeated_apply_does_not_rewrite_audit_timestamps() {
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn future_sequence_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_future_sequence").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query("update __crank_migrations set version = 4 where version = 3")
|
||||
sqlx::query("update __crank_migrations set version = 12 where version = 11")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -288,29 +307,28 @@ async fn future_sequence_fails_closed() {
|
||||
"future_version"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthy_v2_is_reported_as_migration_required_and_upgrades_to_v3() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v2_to_v3_identity").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
remove_v4_schema(&pool).await;
|
||||
sqlx::query("delete from __crank_migrations where version = 3")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v3_schema(&pool).await;
|
||||
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 2,
|
||||
target: 3,
|
||||
target: 11,
|
||||
}
|
||||
);
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::Current { version: 3 }
|
||||
MigrationPreflight::Current { version: 11 }
|
||||
);
|
||||
let trace_column: bool = sqlx::query_scalar(
|
||||
"select exists (
|
||||
@@ -325,12 +343,183 @@ async fn healthy_v2_is_reported_as_migration_required_and_upgrades_to_v3() {
|
||||
.unwrap();
|
||||
assert!(trace_column);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthy_v3_upgrades_to_v4_with_honest_legacy_snapshot_provenance() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v3_to_v4_lifecycle").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
remove_v4_schema(&pool).await;
|
||||
sqlx::raw_sql(
|
||||
"insert into operations
|
||||
(id, workspace_id, name, display_name, category, protocol, security_level, status,
|
||||
current_draft_version, latest_published_version, created_at, updated_at, published_at)
|
||||
values ('op_v3_cutover', 'ws_default', 'v3_cutover', 'V3 Cutover', 'general', 'rest',
|
||||
'standard', 'published', 1, 1, now(), now(), now());
|
||||
insert into operation_versions
|
||||
(operation_id, version, status, target_json, input_schema_json, output_schema_json,
|
||||
input_mapping_json, output_mapping_json, execution_config_json,
|
||||
tool_description_json, created_at)
|
||||
values ('op_v3_cutover', 1, 'published', '{}'::jsonb, '{}'::jsonb, '{}'::jsonb,
|
||||
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, now());
|
||||
insert into published_operations(operation_id, version, published_at, published_by)
|
||||
values ('op_v3_cutover', 1, now(), 'legacy-owner');",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 3,
|
||||
target: 11,
|
||||
}
|
||||
);
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let row = sqlx::query(
|
||||
"select name, display_name, snapshot_provenance, snapshot_observed_at,
|
||||
published_at, published_by
|
||||
from operation_versions where operation_id = 'op_v3_cutover' and version = 1",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.get::<String, _>("name"), "v3_cutover");
|
||||
assert_eq!(row.get::<String, _>("display_name"), "V3 Cutover");
|
||||
assert_eq!(
|
||||
row.get::<String, _>("snapshot_provenance"),
|
||||
"legacy_observed"
|
||||
);
|
||||
assert!(
|
||||
row.try_get::<time::OffsetDateTime, _>("snapshot_observed_at")
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
row.try_get::<time::OffsetDateTime, _>("published_at")
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
row.try_get::<Option<String>, _>("published_by").unwrap(),
|
||||
Some("legacy-owner".to_owned())
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn healthy_v4_upgrades_to_v5_without_fabricating_legacy_outcomes() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v4_to_v5_outcome").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
remove_v5_schema(&pool).await;
|
||||
sqlx::query(
|
||||
"insert into operations
|
||||
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
|
||||
values ('op_v4_history', 'ws_default', 'v4_history', 'V4 History', 'rest',
|
||||
'draft', now(), now())",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into invocation_logs
|
||||
(id, workspace_id, operation_id, source, level, status, tool_name, message,
|
||||
duration_ms, request_preview_json, response_preview_json, created_at)
|
||||
values ('legacy_v4_log', 'ws_default', 'op_v4_history', 'admin', 'info',
|
||||
'success', 'legacy', 'safe', 1, '{}'::jsonb, '{}'::jsonb, now())",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 4,
|
||||
target: 11,
|
||||
}
|
||||
);
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let row = sqlx::query(
|
||||
"select operation_version, execution_stage, execution_error_code,
|
||||
retryability, outcome_certainty
|
||||
from invocation_logs where id = 'legacy_v4_log'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
for column in [
|
||||
"operation_version",
|
||||
"execution_stage",
|
||||
"execution_error_code",
|
||||
"retryability",
|
||||
"outcome_certainty",
|
||||
] {
|
||||
assert!(
|
||||
row.try_get::<Option<String>, _>(column)
|
||||
.is_ok_and(|value| value.is_none())
|
||||
|| row
|
||||
.try_get::<Option<i32>, _>(column)
|
||||
.is_ok_and(|value| value.is_none())
|
||||
);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn failed_operation_lifecycle_migration_rolls_back_schema_and_ledger() {
|
||||
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
|
||||
let database_url =
|
||||
crank_test_support::postgres_schema_url("test_operation_lifecycle_rollback").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
remove_v4_schema(&pool).await;
|
||||
let schema: String = sqlx::query_scalar("select current_schema()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let failure_trigger = format!(
|
||||
"create function reject_story17_v4() returns event_trigger language plpgsql as $$
|
||||
begin
|
||||
if current_schema() = '{schema}' and current_query() like '%operation_versions_immutable_guard%' then
|
||||
raise exception 'injected v4 ddl failure';
|
||||
end if;
|
||||
end $$;
|
||||
create event trigger reject_story17_v4 on ddl_command_start
|
||||
execute function reject_story17_v4();"
|
||||
);
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||
sqlx::raw_sql(
|
||||
"drop event trigger reject_story17_v4;
|
||||
drop function reject_story17_v4();",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(error.code(), "apply_failed");
|
||||
assert_eq!(error.version(), Some(4));
|
||||
let lifecycle_column: bool = sqlx::query_scalar(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'operation_versions'
|
||||
and column_name = 'snapshot_provenance'
|
||||
)",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!lifecycle_column);
|
||||
let ledger_v4: i64 =
|
||||
sqlx::query_scalar("select count(*) from __crank_migrations where version = 4")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ledger_v4, 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn v2_with_partial_v3_objects_fails_before_apply() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v2_partial_v3").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
remove_v4_schema(&pool).await;
|
||||
sqlx::query("delete from __crank_migrations where version = 3")
|
||||
.execute(&pool)
|
||||
.await
|
||||
@@ -343,12 +532,10 @@ async fn v2_with_partial_v3_objects_fails_before_apply() {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "partial_sequence");
|
||||
assert_eq!(error.stage(), "preflight.schema");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v3_rejects_same_named_constraint_or_index_with_wrong_definition() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v3_named_drift").await;
|
||||
@@ -369,7 +556,6 @@ async fn v3_rejects_same_named_constraint_or_index_with_wrong_definition() {
|
||||
.code(),
|
||||
"partial_sequence"
|
||||
);
|
||||
|
||||
sqlx::raw_sql(
|
||||
"alter table invocation_logs drop constraint invocation_logs_trace_id_format_check;
|
||||
alter table invocation_logs add constraint invocation_logs_trace_id_format_check
|
||||
@@ -393,7 +579,78 @@ async fn v3_rejects_same_named_constraint_or_index_with_wrong_definition() {
|
||||
"partial_sequence"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn v5_rejects_same_named_execution_code_constraint_with_wrong_definition() {
|
||||
let database_url =
|
||||
crank_test_support::postgres_schema_url("test_v5_execution_code_named_drift").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::raw_sql(
|
||||
"alter table invocation_logs
|
||||
drop constraint invocation_logs_execution_error_code_check;
|
||||
alter table invocation_logs
|
||||
add constraint invocation_logs_execution_error_code_check check (true) not valid;",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "partial_sequence");
|
||||
assert_eq!(error.stage(), "preflight.schema");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn v11_rejects_same_named_product_event_contract_drift() {
|
||||
let database_url =
|
||||
crank_test_support::postgres_schema_url("test_v11_product_event_named_drift").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
|
||||
sqlx::raw_sql(
|
||||
"alter table product_events drop constraint product_events_name_check;
|
||||
alter table product_events add constraint product_events_name_check check (true) not valid;",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let constraint_error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(constraint_error.code(), "partial_sequence");
|
||||
assert_eq!(constraint_error.stage(), "preflight.schema");
|
||||
|
||||
sqlx::raw_sql(
|
||||
"alter table product_events drop constraint product_events_name_check;
|
||||
alter table product_events add constraint product_events_name_check check (event_name in (
|
||||
'onboarding_eligible', 'onboarding_started', 'onboarding_resumed',
|
||||
'onboarding_dismissed', 'onboarding_abandoned', 'onboarding_completed'
|
||||
));
|
||||
drop index product_events_workspace_occurred_idx;
|
||||
create index product_events_workspace_occurred_idx
|
||||
on product_events(occurred_at, workspace_id, id);",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let index_error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(index_error.code(), "partial_sequence");
|
||||
assert_eq!(index_error.stage(), "preflight.schema");
|
||||
|
||||
sqlx::raw_sql(
|
||||
"drop index product_events_workspace_occurred_idx;
|
||||
create index product_events_workspace_occurred_idx
|
||||
on product_events(workspace_id, occurred_at, id);
|
||||
create or replace function crank_reject_product_event_mutation()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
raise exception 'ProductEvent is append-only' using errcode = '23514';
|
||||
end;
|
||||
$$;",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let function_error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(function_error.code(), "partial_sequence");
|
||||
assert_eq!(function_error.stage(), "preflight.schema");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn v3_upgrade_ignores_oversized_legacy_request_ids_in_partial_index() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v3_legacy_request_id").await;
|
||||
@@ -419,19 +676,18 @@ async fn v3_upgrade_ignores_oversized_legacy_request_ids_in_partial_index() {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v4_schema(&pool).await;
|
||||
sqlx::query("delete from __crank_migrations where version = 3")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v3_schema(&pool).await;
|
||||
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::Current { version: 3 }
|
||||
MigrationPreflight::Current { version: 11 }
|
||||
);
|
||||
}
|
||||
|
||||
async fn remove_v3_schema(pool: &sqlx::PgPool) {
|
||||
sqlx::raw_sql(
|
||||
"drop index if exists invocation_logs_workspace_request_id_idx;
|
||||
@@ -441,7 +697,147 @@ async fn remove_v3_schema(pool: &sqlx::PgPool) {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn remove_v4_schema(pool: &sqlx::PgPool) {
|
||||
remove_v5_schema(pool).await;
|
||||
sqlx::raw_sql(
|
||||
"drop trigger if exists operation_versions_immutable_guard on operation_versions;
|
||||
drop function if exists crank_guard_operation_version_immutable();
|
||||
drop trigger if exists published_operations_monotonic_guard on published_operations;
|
||||
drop function if exists crank_guard_published_operation_pointer();
|
||||
drop trigger if exists operations_latest_pointer_monotonic_guard on operations;
|
||||
drop function if exists crank_guard_operation_latest_pointer();
|
||||
alter table operation_versions
|
||||
drop constraint if exists operation_versions_snapshot_provenance_check,
|
||||
drop column if exists name,
|
||||
drop column if exists display_name,
|
||||
drop column if exists category,
|
||||
drop column if exists protocol,
|
||||
drop column if exists security_level,
|
||||
drop column if exists snapshot_provenance,
|
||||
drop column if exists snapshot_observed_at,
|
||||
drop column if exists published_at,
|
||||
drop column if exists published_by;
|
||||
delete from __crank_migrations where version = 4;",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
async fn remove_v5_schema(pool: &sqlx::PgPool) {
|
||||
remove_v6_schema(pool).await;
|
||||
sqlx::raw_sql(
|
||||
"drop index if exists invocation_logs_workspace_operation_version_idx;
|
||||
alter table invocation_logs
|
||||
drop column if exists operation_version,
|
||||
drop column if exists execution_stage,
|
||||
drop column if exists execution_error_code,
|
||||
drop column if exists retryability,
|
||||
drop column if exists outcome_certainty;
|
||||
delete from __crank_migrations where version = 5;",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
async fn remove_v6_schema(pool: &sqlx::PgPool) {
|
||||
remove_v7_schema(pool).await;
|
||||
sqlx::raw_sql(
|
||||
"drop index if exists platform_api_keys_workspace_name_active_idx;
|
||||
create unique index if not exists platform_api_keys_workspace_name_idx on platform_api_keys(workspace_id, name);
|
||||
delete from __crank_migrations where version = 6;",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
async fn remove_v7_schema(pool: &sqlx::PgPool) {
|
||||
remove_v8_schema(pool).await;
|
||||
sqlx::raw_sql(
|
||||
"alter table secret_versions drop constraint if exists secret_versions_target_all_or_none_check,
|
||||
drop constraint if exists secret_versions_target_epoch_check,
|
||||
drop constraint if exists secret_versions_master_key_epoch_check,
|
||||
drop column if exists target_master_key_epoch, drop column if exists target_key_version,
|
||||
drop column if exists target_ciphertext, drop column if exists master_key_epoch;
|
||||
drop table if exists master_key_rotations;
|
||||
drop table if exists master_key_identities;
|
||||
delete from __crank_migrations where version = 7;",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
async fn remove_v8_schema(pool: &sqlx::PgPool) {
|
||||
remove_v9_schema(pool).await;
|
||||
sqlx::raw_sql(
|
||||
"drop table if exists admin_security_audit_events;
|
||||
drop table if exists admin_login_backoff;
|
||||
drop table if exists admin_bootstrap_contracts;
|
||||
alter table user_sessions
|
||||
drop constraint if exists user_sessions_csrf_hash_check,
|
||||
drop column if exists csrf_hash,
|
||||
drop column if exists revoked_at;
|
||||
delete from __crank_migrations where version = 8;",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
async fn remove_v9_schema(pool: &sqlx::PgPool) {
|
||||
remove_v10_schema(pool).await;
|
||||
sqlx::raw_sql(
|
||||
"drop trigger if exists published_agents_monotonic_guard on published_agents;
|
||||
drop function if exists crank_reject_published_agent_pointer_rewind();
|
||||
drop trigger if exists agent_operation_bindings_immutable_guard on agent_operation_bindings;
|
||||
drop function if exists crank_reject_published_agent_binding_mutation();
|
||||
drop trigger if exists agent_versions_immutable_guard on agent_versions;
|
||||
drop function if exists crank_reject_published_agent_version_mutation();
|
||||
alter table published_agents
|
||||
drop constraint if exists published_agents_catalog_revision_check,
|
||||
drop column if exists catalog_revision;
|
||||
alter table agents
|
||||
drop constraint if exists agents_catalog_revision_check,
|
||||
drop column if exists catalog_revision;
|
||||
delete from __crank_migrations where version = 9;",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
async fn remove_v10_schema(pool: &sqlx::PgPool) {
|
||||
remove_v11_schema(pool).await;
|
||||
sqlx::raw_sql(
|
||||
"drop index if exists approval_requests_pending_scope_fingerprint_idx;
|
||||
drop index if exists approval_requests_workspace_request_trace_idx;
|
||||
alter table approval_requests drop constraint if exists approval_requests_request_id_check;
|
||||
alter table approval_requests drop constraint if exists approval_requests_trace_id_check;
|
||||
alter table approval_requests drop column if exists request_id;
|
||||
alter table approval_requests drop column if exists trace_id;
|
||||
create unique index if not exists approval_requests_pending_fingerprint_idx
|
||||
on approval_requests(agent_id, operation_id, operation_version, request_fingerprint)
|
||||
where status = 'pending' and request_fingerprint is not null;
|
||||
delete from __crank_migrations where version = 10;",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
async fn remove_v11_schema(pool: &sqlx::PgPool) {
|
||||
sqlx::raw_sql(
|
||||
"drop table if exists onboarding_selections;
|
||||
drop trigger if exists product_events_append_only_guard on product_events;
|
||||
drop function if exists crank_reject_product_event_mutation();
|
||||
drop table if exists product_event_daily_rollups;
|
||||
drop table if exists product_events;
|
||||
drop index if exists invocation_logs_workspace_agent_key_success_idx;
|
||||
alter table invocation_logs drop constraint if exists invocation_logs_platform_key_scope_fk;
|
||||
alter table invocation_logs drop column if exists platform_api_key_id;
|
||||
alter table platform_api_keys drop constraint if exists platform_api_keys_workspace_agent_id_unique;
|
||||
delete from __crank_migrations where version = 11;",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn partial_sequence_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_partial_sequence").await;
|
||||
@@ -459,7 +855,6 @@ async fn partial_sequence_fails_closed() {
|
||||
"partial_sequence"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_relation_with_current_ledger_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_missing_relation").await;
|
||||
@@ -473,7 +868,6 @@ async fn missing_relation_with_current_ledger_fails_closed() {
|
||||
assert_eq!(error.code(), "partial_sequence");
|
||||
assert_eq!(error.stage(), "preflight.schema");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregistered_legacy_extension_provenance_is_rejected() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_legacy_extension").await;
|
||||
@@ -492,11 +886,9 @@ async fn unregistered_legacy_extension_provenance_is_rejected() {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "legacy_conflict");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn any_owned_relation_without_core_ledger_is_partial() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_owned_partial").await;
|
||||
@@ -508,7 +900,6 @@ async fn any_owned_relation_without_core_ledger_is_partial() {
|
||||
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "partial_sequence");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_ledger_with_structural_drift_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_structural_drift").await;
|
||||
@@ -522,7 +913,6 @@ async fn current_ledger_with_structural_drift_fails_closed() {
|
||||
assert_eq!(error.code(), "partial_sequence");
|
||||
assert_eq!(error.stage(), "preflight.schema");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tampered_legacy_audit_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_tampered_audit").await;
|
||||
@@ -537,173 +927,3 @@ async fn tampered_legacy_audit_fails_closed() {
|
||||
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "legacy_conflict");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_consolidation_rolls_back_all_changes() {
|
||||
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
|
||||
let database_url = crank_test_support::postgres_schema_url("test_apply_rollback").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"insert into workspaces (id, slug, display_name, status, settings_json, created_at, updated_at)
|
||||
values ('rollback_preserved', 'rollback-preserved', 'Rollback Preserved', 'active', '{}'::jsonb, now(), now())",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"drop table __crank_migrations, __crank_migration_legacy_audit,
|
||||
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let schema: String = sqlx::query_scalar("select current_schema()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let failure_trigger = format!(
|
||||
"create function reject_story14_v2() returns event_trigger language plpgsql as $$
|
||||
begin
|
||||
if current_schema() = '{schema}' and current_query() like '%__crank_migrations%' then
|
||||
raise exception 'injected v2 ddl failure';
|
||||
end if;
|
||||
end $$;
|
||||
create event trigger reject_story14_v2 on ddl_command_start
|
||||
execute function reject_story14_v2();"
|
||||
);
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||
sqlx::raw_sql(
|
||||
"drop event trigger reject_story14_v2;
|
||||
drop function reject_story14_v2();",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(error.code(), "apply_failed");
|
||||
|
||||
for relation in [
|
||||
"__crank_migrations",
|
||||
"__crank_migration_legacy_audit",
|
||||
"__crank_mcp_migrations",
|
||||
"mcp_transport_sessions",
|
||||
"__crank_ext_migrations",
|
||||
] {
|
||||
let present: bool = sqlx::query_scalar(
|
||||
"select to_regclass(format('%I.%I', current_schema(), $1)) is not null",
|
||||
)
|
||||
.bind(relation)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!present, "{relation} must roll back with failed v2 DDL");
|
||||
}
|
||||
let preserved: String =
|
||||
sqlx::query_scalar("select display_name from workspaces where id = 'rollback_preserved'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(preserved, "Rollback Preserved");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_request_trace_identity_migration_rolls_back_all_changes() {
|
||||
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
|
||||
let database_url =
|
||||
crank_test_support::postgres_schema_url("test_trace_identity_rollback").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::raw_sql(
|
||||
"insert into operations
|
||||
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
|
||||
values ('op_trace_rollback', 'ws_default', 'trace-rollback', 'Trace rollback',
|
||||
'rest', 'draft', now(), now());
|
||||
insert into invocation_logs
|
||||
(id, workspace_id, operation_id, source, level, status, tool_name, message,
|
||||
duration_ms, request_preview_json, response_preview_json, created_at)
|
||||
values ('trace_rollback_preserved', 'ws_default', 'op_trace_rollback', 'admin',
|
||||
'info', 'success', 'trace_rollback', 'safe preserved row', 1,
|
||||
'{}'::jsonb, '{}'::jsonb, now());",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("delete from __crank_migrations where version = 3")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v3_schema(&pool).await;
|
||||
let schema: String = sqlx::query_scalar("select current_schema()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let failure_trigger = format!(
|
||||
"create function reject_story15_v3() returns event_trigger language plpgsql as $$
|
||||
begin
|
||||
if current_schema() = '{schema}' and current_query() like '%invocation_logs_workspace_trace_id_idx%' then
|
||||
raise exception 'injected v3 ddl failure';
|
||||
end if;
|
||||
end $$;
|
||||
create event trigger reject_story15_v3 on ddl_command_start
|
||||
execute function reject_story15_v3();"
|
||||
);
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||
sqlx::raw_sql(
|
||||
"drop event trigger reject_story15_v3;
|
||||
drop function reject_story15_v3();",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(error.code(), "apply_failed");
|
||||
assert_eq!(error.version(), Some(3));
|
||||
|
||||
let trace_column: bool = sqlx::query_scalar(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'invocation_logs'
|
||||
and column_name = 'trace_id'
|
||||
)",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!trace_column, "trace_id column must roll back with v3");
|
||||
for index in [
|
||||
"invocation_logs_workspace_request_id_idx",
|
||||
"invocation_logs_workspace_trace_id_idx",
|
||||
] {
|
||||
let present: bool = sqlx::query_scalar(
|
||||
"select to_regclass(format('%I.%I', current_schema(), $1)) is not null",
|
||||
)
|
||||
.bind(index)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!present, "{index} must roll back with failed v3 DDL");
|
||||
}
|
||||
let ledger_v3: i64 =
|
||||
sqlx::query_scalar("select count(*) from __crank_migrations where version = 3")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ledger_v3, 0, "failed v3 must not be recorded as applied");
|
||||
let preserved: String = sqlx::query_scalar(
|
||||
"select message from invocation_logs where id = 'trace_rollback_preserved'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(preserved, "safe preserved row");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_consolidation_rolls_back_all_changes() {
|
||||
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
|
||||
let database_url = crank_test_support::postgres_schema_url("test_apply_rollback").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"insert into workspaces (id, slug, display_name, status, settings_json, created_at, updated_at)
|
||||
values ('rollback_preserved', 'rollback-preserved', 'Rollback Preserved', 'active', '{}'::jsonb, now(), now())",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"drop table __crank_migrations, __crank_migration_legacy_audit,
|
||||
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let schema: String = sqlx::query_scalar("select current_schema()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let failure_trigger = format!(
|
||||
"create function reject_story14_v2() returns event_trigger language plpgsql as $$
|
||||
begin
|
||||
if current_schema() = '{schema}' and current_query() like '%__crank_migrations%' then
|
||||
raise exception 'injected v2 ddl failure';
|
||||
end if;
|
||||
end $$;
|
||||
create event trigger reject_story14_v2 on ddl_command_start
|
||||
execute function reject_story14_v2();"
|
||||
);
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||
sqlx::raw_sql(
|
||||
"drop event trigger reject_story14_v2;
|
||||
drop function reject_story14_v2();",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(error.code(), "apply_failed");
|
||||
for relation in [
|
||||
"__crank_migrations",
|
||||
"__crank_migration_legacy_audit",
|
||||
"__crank_mcp_migrations",
|
||||
"mcp_transport_sessions",
|
||||
"__crank_ext_migrations",
|
||||
] {
|
||||
let present: bool = sqlx::query_scalar(
|
||||
"select to_regclass(format('%I.%I', current_schema(), $1)) is not null",
|
||||
)
|
||||
.bind(relation)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!present, "{relation} must roll back with failed v2 DDL");
|
||||
}
|
||||
let preserved: String =
|
||||
sqlx::query_scalar("select display_name from workspaces where id = 'rollback_preserved'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(preserved, "Rollback Preserved");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_request_trace_identity_migration_rolls_back_all_changes() {
|
||||
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
|
||||
let database_url =
|
||||
crank_test_support::postgres_schema_url("test_trace_identity_rollback").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::raw_sql(
|
||||
"insert into operations
|
||||
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
|
||||
values ('op_trace_rollback', 'ws_default', 'trace-rollback', 'Trace rollback',
|
||||
'rest', 'draft', now(), now());
|
||||
insert into invocation_logs
|
||||
(id, workspace_id, operation_id, source, level, status, tool_name, message,
|
||||
duration_ms, request_preview_json, response_preview_json, created_at)
|
||||
values ('trace_rollback_preserved', 'ws_default', 'op_trace_rollback', 'admin',
|
||||
'info', 'success', 'trace_rollback', 'safe preserved row', 1,
|
||||
'{}'::jsonb, '{}'::jsonb, now());",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v4_schema(&pool).await;
|
||||
sqlx::query("delete from __crank_migrations where version = 3")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v3_schema(&pool).await;
|
||||
let schema: String = sqlx::query_scalar("select current_schema()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let failure_trigger = format!(
|
||||
"create function reject_story15_v3() returns event_trigger language plpgsql as $$
|
||||
begin
|
||||
if current_schema() = '{schema}' and current_query() like '%invocation_logs_workspace_trace_id_idx%' then
|
||||
raise exception 'injected v3 ddl failure';
|
||||
end if;
|
||||
end $$;
|
||||
create event trigger reject_story15_v3 on ddl_command_start
|
||||
execute function reject_story15_v3();"
|
||||
);
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||
sqlx::raw_sql(
|
||||
"drop event trigger reject_story15_v3;
|
||||
drop function reject_story15_v3();",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(error.code(), "apply_failed");
|
||||
assert_eq!(error.version(), Some(3));
|
||||
let trace_column: bool = sqlx::query_scalar(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'invocation_logs'
|
||||
and column_name = 'trace_id'
|
||||
)",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!trace_column, "trace_id column must roll back with v3");
|
||||
for index in [
|
||||
"invocation_logs_workspace_request_id_idx",
|
||||
"invocation_logs_workspace_trace_id_idx",
|
||||
] {
|
||||
let present: bool = sqlx::query_scalar(
|
||||
"select to_regclass(format('%I.%I', current_schema(), $1)) is not null",
|
||||
)
|
||||
.bind(index)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!present, "{index} must roll back with failed v3 DDL");
|
||||
}
|
||||
let ledger_v3: i64 =
|
||||
sqlx::query_scalar("select count(*) from __crank_migrations where version = 3")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ledger_v3, 0, "failed v3 must not be recorded as applied");
|
||||
let preserved: String = sqlx::query_scalar(
|
||||
"select message from invocation_logs where id = 'trace_rollback_preserved'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(preserved, "safe preserved row");
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
use crank_registry::{
|
||||
CreateInvocationLogRequest, InvocationHistoryLossCategory, InvocationHistoryWriteOutcome,
|
||||
ListInvocationLogsQuery, UsageBucket, UsageQuery,
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::common::{TestDatabase, test_invocation_log};
|
||||
use super::common::{TestDatabase, test_invocation_log, test_operation, test_workspace_id};
|
||||
|
||||
#[tokio::test]
|
||||
async fn invocation_history_write_returns_typed_loss_without_error_details() {
|
||||
@@ -29,3 +31,115 @@ async fn invocation_history_write_returns_typed_loss_without_error_details() {
|
||||
);
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "production-size query-plan evidence: inserts 1,000,000 invocation_logs rows"]
|
||||
async fn production_size_invocation_history_queries_stay_bounded() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let pool = database.raw_pool().await;
|
||||
let operation = test_operation("op_history_scale", 1, crank_core::OperationStatus::Draft);
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
insert into invocation_logs (
|
||||
id, workspace_id, operation_id, operation_version, source, level, status,
|
||||
tool_name, message, request_id, trace_id, status_code, duration_ms,
|
||||
error_kind, execution_stage, execution_error_code, retryability, outcome_certainty,
|
||||
request_preview_json, response_preview_json, created_at
|
||||
)
|
||||
select
|
||||
'log_scale_' || series::text,
|
||||
'ws_default',
|
||||
'op_history_scale',
|
||||
1,
|
||||
'admin_test_run',
|
||||
'info',
|
||||
case when series % 10 = 0 then 'error' else 'ok' end,
|
||||
'scale_tool',
|
||||
'scale invocation',
|
||||
'018f0000-0000-7000-8000-' || lpad(series::text, 12, '0'),
|
||||
'0af7651916cd43dd8448eb211c80319c',
|
||||
case when series % 10 = 0 then 500 else 200 end,
|
||||
20 + (series % 250),
|
||||
null,
|
||||
'runtime',
|
||||
case when series % 10 = 0 then 'runtime_internal' else null end,
|
||||
'never',
|
||||
'certain',
|
||||
'{"input":"bounded"}'::jsonb,
|
||||
'{"ok":true}'::jsonb,
|
||||
'2026-03-25T00:00:00Z'::timestamptz + (series || ' seconds')::interval
|
||||
from generate_series(1, 1000000) as series
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let page = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
status: None,
|
||||
outcome_group: None,
|
||||
search_text: None,
|
||||
source: None,
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: None,
|
||||
created_after: Some("2026-03-25T00:00:00Z"),
|
||||
created_before: Some("2026-04-06T00:00:00Z"),
|
||||
cursor_created_at: None,
|
||||
cursor_id: None,
|
||||
limit: 101,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page.len(), 101);
|
||||
|
||||
let summary = registry
|
||||
.summarize_usage(UsageQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
period: crank_core::UsagePeriod::Last7Days,
|
||||
source: None,
|
||||
created_after: "2026-03-25T00:00:00Z",
|
||||
created_before: "2026-04-06T00:00:00Z",
|
||||
bucket: UsageBucket::Day,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary.rollup.calls_total, 1_000_000);
|
||||
|
||||
let explain = sqlx::query(
|
||||
r#"
|
||||
explain
|
||||
select id
|
||||
from invocation_logs
|
||||
where workspace_id = 'ws_default'
|
||||
and operation_id = 'op_history_scale'
|
||||
and created_at >= '2026-03-25T00:00:00Z'::timestamptz
|
||||
and created_at < '2026-04-06T00:00:00Z'::timestamptz
|
||||
order by created_at desc, id desc
|
||||
limit 101
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|row| row.get::<String, _>(0))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
explain.contains("Index Scan") || explain.contains("Bitmap Index Scan"),
|
||||
"{explain}"
|
||||
);
|
||||
assert!(!explain.contains("Seq Scan"), "{explain}");
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user