995 lines
33 KiB
Rust
995 lines
33 KiB
Rust
#![allow(dead_code, unused_imports)]
|
|
|
|
use super::common::*;
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use axum::{Json, Router, http::HeaderMap, routing::post};
|
|
use crank_core::{AuditError, AuditEvent, AuditSink, PlatformApiKeyKind, SecretKind};
|
|
use serde_json::{Value, json};
|
|
use serial_test::serial;
|
|
use tokio::net::TcpListener;
|
|
|
|
#[derive(Clone, Default)]
|
|
struct RecordingAuditSink {
|
|
events: Arc<Mutex<Vec<AuditEvent>>>,
|
|
}
|
|
|
|
impl RecordingAuditSink {
|
|
fn events(&self) -> Vec<AuditEvent> {
|
|
self.events.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl AuditSink for RecordingAuditSink {
|
|
async fn record(&self, event: AuditEvent) -> Result<(), AuditError> {
|
|
self.events.lock().unwrap().push(event);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial]
|
|
async fn machine_key_raw_value_is_create_only_and_duplicate_name_is_typed_conflict() {
|
|
let registry = test_registry().await;
|
|
let storage_root = test_storage_root("credential_lifecycle_keys");
|
|
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
|
let client = authorized_client(&base_url).await;
|
|
|
|
let agent_id = create_agent(&client, &base_url, "credential-lifecycle-agent").await;
|
|
|
|
let created = post_json(
|
|
&client,
|
|
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
|
json!({"name": "primary-mcp-key", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read", "write"]}),
|
|
)
|
|
.await;
|
|
let raw_key = created["secret"]
|
|
.as_str()
|
|
.expect("create response must disclose the raw key once")
|
|
.to_owned();
|
|
let random_part = raw_key
|
|
.strip_prefix("crk_")
|
|
.expect("MCP key must use the MCP bearer marker");
|
|
assert_eq!(random_part.len(), 43, "32 raw bytes in unpadded base64url");
|
|
assert!(
|
|
random_part
|
|
.bytes()
|
|
.all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') })
|
|
);
|
|
assert_eq!(
|
|
created["connection"]["endpoint"],
|
|
"http://localhost:3000/mcp/v1/default/credential-lifecycle-agent"
|
|
);
|
|
assert!(
|
|
created["connection"]["clients"]
|
|
.as_array()
|
|
.is_some_and(|clients| !clients.is_empty())
|
|
);
|
|
|
|
let approval = post_json(
|
|
&client,
|
|
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
|
json!({"name": "approval-without-mcp-config", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"]}),
|
|
)
|
|
.await;
|
|
assert!(approval.get("connection").is_none());
|
|
|
|
let listed_text = client
|
|
.get(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
assert!(!listed_text.contains(&raw_key));
|
|
assert!(!listed_text.contains("secret_hash"));
|
|
assert!(!listed_text.contains("\"secret\""));
|
|
|
|
let duplicate = client
|
|
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
|
.json(&json!({
|
|
"name": "primary-mcp-key",
|
|
"key_kind": PlatformApiKeyKind::McpClient,
|
|
"scopes": ["read"]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(duplicate.status(), reqwest::StatusCode::CONFLICT);
|
|
let duplicate_body = duplicate.text().await.unwrap();
|
|
assert!(!duplicate_body.contains(&raw_key));
|
|
assert!(!duplicate_body.contains("secret_hash"));
|
|
assert!(!duplicate_body.contains("duplicate key value"));
|
|
let duplicate_json: Value = serde_json::from_str(&duplicate_body).unwrap();
|
|
assert_eq!(
|
|
duplicate_json["error"]["code"],
|
|
"platform_api_key_name_conflict"
|
|
);
|
|
|
|
for payload in [
|
|
json!({"name": "n".repeat(129), "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read"]}),
|
|
json!({"name": "duplicate-scope", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read", "read"]}),
|
|
json!({"name": "past-expiry", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read"], "expires_at": "2020-01-01T00:00:00Z"}),
|
|
] {
|
|
assert_post_error(
|
|
&client,
|
|
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
|
payload,
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
"validation_error",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let canonical = post_json(
|
|
&client,
|
|
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
|
json!({"name": "canonical-approval-origin", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": ["HTTPS://Example.COM:443/", "http://EXAMPLE.com:80/"]}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
canonical["api_key"]["api_key"]["allowed_origins"],
|
|
json!(["https://example.com", "http://example.com"])
|
|
);
|
|
for (index, origin) in [
|
|
"https://[::1",
|
|
"https://example.test:bad/",
|
|
"https://user:pass@example.test/",
|
|
"https://example.test/path",
|
|
"https://example.test/?token=origin-canary",
|
|
"https://example.test/#fragment",
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
assert_post_error(
|
|
&client,
|
|
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
|
json!({"name": format!("malformed-origin-{index}"), "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": [origin]}),
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
"validation_error",
|
|
)
|
|
.await;
|
|
}
|
|
assert_post_error(
|
|
&client,
|
|
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
|
json!({"name": "duplicate-canonical-origin", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": ["https://EXAMPLE.test:443/", "https://example.test/"]}),
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
"validation_error",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial]
|
|
async fn deleted_machine_key_name_can_be_reused_and_terminal_state_cannot_regress() {
|
|
let registry = test_registry().await;
|
|
let storage_root = test_storage_root("credential_lifecycle_key_reuse");
|
|
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
|
let client = authorized_client(&base_url).await;
|
|
|
|
let agent_id = create_agent(&client, &base_url, "credential-key-reuse-agent").await;
|
|
|
|
let first = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
|
.json(&json!({
|
|
"name": "replaceable-key",
|
|
"key_kind": PlatformApiKeyKind::McpClient,
|
|
"scopes": ["read"]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let first_key_id = first["api_key"]["api_key"]["id"].as_str().unwrap();
|
|
|
|
let delete_response = client
|
|
.delete(format!(
|
|
"{base_url}/agents/{agent_id}/platform-api-keys/{first_key_id}"
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(delete_response.status(), reqwest::StatusCode::NO_CONTENT);
|
|
|
|
let revoke_deleted = client
|
|
.post(format!(
|
|
"{base_url}/agents/{agent_id}/platform-api-keys/{first_key_id}/revoke"
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(revoke_deleted.status(), reqwest::StatusCode::CONFLICT);
|
|
let revoke_body: Value = serde_json::from_str(&revoke_deleted.text().await.unwrap()).unwrap();
|
|
assert_eq!(revoke_body["error"]["code"], "platform_api_key_not_active");
|
|
|
|
let second = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
|
.json(&json!({
|
|
"name": "replaceable-key",
|
|
"key_kind": PlatformApiKeyKind::McpClient,
|
|
"scopes": ["read"]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let second_key_id = second["api_key"]["api_key"]["id"].as_str().unwrap();
|
|
assert_ne!(first_key_id, second_key_id);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial]
|
|
async fn secret_plaintext_is_write_only_and_auth_profile_stores_reference() {
|
|
let registry = test_registry().await;
|
|
let storage_root = test_storage_root("credential_lifecycle_secrets");
|
|
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
|
let client = authorized_client(&base_url).await;
|
|
let canary = "secret-canary-1-10-do-not-leak";
|
|
|
|
let created_response = client
|
|
.post(format!("{base_url}/secrets"))
|
|
.json(&json!({
|
|
"name": "crm-bearer-token",
|
|
"kind": SecretKind::Token,
|
|
"value": { "token": canary }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let created_text = created_response.text().await.unwrap();
|
|
assert!(!created_text.contains(canary));
|
|
assert!(!created_text.contains("ciphertext"));
|
|
let created: Value = serde_json::from_str(&created_text).unwrap();
|
|
let secret_id = created["id"].as_str().unwrap();
|
|
|
|
let listed_text = client
|
|
.get(format!("{base_url}/secrets"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
assert!(!listed_text.contains(canary));
|
|
assert!(!listed_text.contains("ciphertext"));
|
|
|
|
let fetched_text = client
|
|
.get(format!("{base_url}/secrets/{secret_id}"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
assert!(!fetched_text.contains(canary));
|
|
assert!(!fetched_text.contains("ciphertext"));
|
|
|
|
let auth_profile_text = client
|
|
.post(format!("{base_url}/auth-profiles"))
|
|
.json(&json!({
|
|
"name": "crm-bearer",
|
|
"kind": "bearer",
|
|
"config": {
|
|
"bearer": {
|
|
"header_name": "Authorization",
|
|
"secret_id": secret_id
|
|
}
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
assert!(!auth_profile_text.contains(canary));
|
|
assert!(!auth_profile_text.contains("ciphertext"));
|
|
let auth_profile: Value = serde_json::from_str(&auth_profile_text).unwrap();
|
|
assert_eq!(auth_profile["config"]["bearer"]["secret_id"], secret_id);
|
|
|
|
for (name, kind, value) in [
|
|
("null-token", SecretKind::Token, Value::Null),
|
|
(
|
|
"wrong-token-shape",
|
|
SecretKind::Token,
|
|
json!({"token": "token", "extra": "field"}),
|
|
),
|
|
(
|
|
"wrong-header-shape",
|
|
SecretKind::Header,
|
|
json!({"header_name": "X-Token"}),
|
|
),
|
|
(
|
|
"wrong-username-password-shape",
|
|
SecretKind::UsernamePassword,
|
|
json!({"username": "user"}),
|
|
),
|
|
] {
|
|
assert_post_error(
|
|
&client,
|
|
format!("{base_url}/secrets"),
|
|
json!({"name": name, "kind": kind, "value": value}),
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
"validation_error",
|
|
)
|
|
.await;
|
|
}
|
|
assert_post_error(
|
|
&client,
|
|
format!("{base_url}/secrets"),
|
|
json!({"name": "oversized-secret", "kind": SecretKind::Generic, "value": "x".repeat(65_537)}),
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
"validation_error",
|
|
)
|
|
.await;
|
|
for value in [
|
|
Value::Null,
|
|
json!({"token": "token", "extra": "field"}),
|
|
json!("x".repeat(65_537)),
|
|
] {
|
|
assert_post_error(
|
|
&client,
|
|
format!("{base_url}/secrets/{secret_id}/rotate"),
|
|
json!({"value": value}),
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
"validation_error",
|
|
)
|
|
.await;
|
|
}
|
|
let current = assert_success_json(
|
|
client
|
|
.get(format!("{base_url}/secrets/{secret_id}"))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(current["current_version"], 1);
|
|
|
|
for payload in [
|
|
json!({"name": "", "kind": "bearer", "config": {"bearer": {"header_name": "Authorization", "secret_id": secret_id}}}),
|
|
json!({"name": "n".repeat(129), "kind": "bearer", "config": {"bearer": {"header_name": "Authorization", "secret_id": secret_id}}}),
|
|
json!({"name": "invalid-header-name", "kind": "bearer", "config": {"bearer": {"header_name": "Bad Header", "secret_id": secret_id}}}),
|
|
json!({"name": "invalid-query-name", "kind": "api_key_query", "config": {"api_key_query": {"param_name": "bad name", "secret_id": secret_id}}}),
|
|
] {
|
|
assert_post_error(
|
|
&client,
|
|
format!("{base_url}/auth-profiles"),
|
|
payload,
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
"validation_error",
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial]
|
|
async fn credential_mutations_emit_bounded_non_secret_audit_events() {
|
|
let registry = test_registry().await;
|
|
let storage_root = test_storage_root("credential_lifecycle_audit");
|
|
let audit_sink = RecordingAuditSink::default();
|
|
let base_url = spawn_admin_api(build_test_app_with_audit_sink(
|
|
registry,
|
|
storage_root,
|
|
Arc::new(audit_sink.clone()),
|
|
))
|
|
.await;
|
|
let client = authorized_client(&base_url).await;
|
|
let request_id = "req_credential_audit_1";
|
|
let traceparent = "00-11111111111111111111111111111111-2222222222222222-01";
|
|
|
|
let secret = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/secrets"))
|
|
.header("x-request-id", request_id)
|
|
.header("traceparent", traceparent)
|
|
.json(&json!({
|
|
"name": "audit-token",
|
|
"kind": SecretKind::Token,
|
|
"value": { "token": "audit-secret-canary" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let secret_id = secret["id"].as_str().unwrap();
|
|
|
|
assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
|
.header("x-request-id", request_id)
|
|
.header("traceparent", traceparent)
|
|
.json(&json!({
|
|
"value": { "token": "audit-rotated-canary" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
|
|
let auth_profile = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/auth-profiles"))
|
|
.header("x-request-id", request_id)
|
|
.header("traceparent", traceparent)
|
|
.json(&json!({
|
|
"name": "audit-profile",
|
|
"kind": "bearer",
|
|
"config": {
|
|
"bearer": {
|
|
"header_name": "Authorization",
|
|
"secret_id": secret_id
|
|
}
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let auth_profile_id = auth_profile["id"].as_str().unwrap();
|
|
|
|
let delete_denied = client
|
|
.delete(format!("{base_url}/secrets/{secret_id}"))
|
|
.header("x-request-id", request_id)
|
|
.header("traceparent", traceparent)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(delete_denied.status(), reqwest::StatusCode::CONFLICT);
|
|
|
|
let created_agent = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/agents"))
|
|
.header("x-request-id", request_id)
|
|
.header("traceparent", traceparent)
|
|
.json(&json!({
|
|
"slug": "credential-audit-agent",
|
|
"display_name": "Credential Audit Agent",
|
|
"description": "Agent for credential audit tests.",
|
|
"instructions": {},
|
|
"tool_selection_policy": {}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let agent_id = created_agent["agent_id"].as_str().unwrap();
|
|
let created_key = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
|
.header("x-request-id", request_id)
|
|
.header("traceparent", traceparent)
|
|
.json(&json!({
|
|
"name": "audit-mcp-key",
|
|
"key_kind": PlatformApiKeyKind::McpClient,
|
|
"scopes": ["read"]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let raw_key = created_key["secret"].as_str().unwrap();
|
|
let key_id = created_key["api_key"]["api_key"]["id"].as_str().unwrap();
|
|
let duplicate_key = client
|
|
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
|
.header("x-request-id", request_id)
|
|
.header("traceparent", traceparent)
|
|
.json(&json!({
|
|
"name": "audit-mcp-key",
|
|
"key_kind": PlatformApiKeyKind::McpClient,
|
|
"scopes": ["read"]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(duplicate_key.status(), reqwest::StatusCode::CONFLICT);
|
|
let validation_key = client
|
|
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
|
.header("x-request-id", request_id)
|
|
.header("traceparent", traceparent)
|
|
.json(&json!({
|
|
"name": "n".repeat(129),
|
|
"key_kind": PlatformApiKeyKind::McpClient,
|
|
"scopes": ["read"]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(validation_key.status(), reqwest::StatusCode::BAD_REQUEST);
|
|
let invalid_profile = client
|
|
.post(format!("{base_url}/auth-profiles"))
|
|
.header("x-request-id", request_id)
|
|
.header("traceparent", traceparent)
|
|
.json(&json!({
|
|
"name": "audit-invalid-profile",
|
|
"kind": "bearer",
|
|
"config": {
|
|
"bearer": {
|
|
"header_name": "Authorization",
|
|
"secret_id": "secret_missing_for_audit"
|
|
}
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(invalid_profile.status(), reqwest::StatusCode::NOT_FOUND);
|
|
client
|
|
.post(format!(
|
|
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}/revoke"
|
|
))
|
|
.header("x-request-id", request_id)
|
|
.header("traceparent", traceparent)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let delete_response = client
|
|
.delete(format!(
|
|
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}"
|
|
))
|
|
.header("x-request-id", request_id)
|
|
.header("traceparent", traceparent)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(delete_response.status(), reqwest::StatusCode::NO_CONTENT);
|
|
|
|
let listed_after_delete = assert_success_json(
|
|
client
|
|
.get(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let deleted_metadata = listed_after_delete["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|item| item["api_key"]["id"] == key_id)
|
|
.expect("delete must preserve key metadata for provenance");
|
|
assert_eq!(deleted_metadata["api_key"]["status"], "deleted");
|
|
let listed_after_delete_text = serde_json::to_string(&listed_after_delete).unwrap();
|
|
assert!(!listed_after_delete_text.contains(raw_key));
|
|
|
|
let events = audit_sink.events();
|
|
let actions: Vec<&str> = events.iter().map(|event| event.action.as_str()).collect();
|
|
for expected in [
|
|
"credential.secret.created",
|
|
"credential.secret.rotated",
|
|
"credential.auth_profile.created",
|
|
"credential.secret.delete_denied",
|
|
"credential.platform_api_key.created",
|
|
"credential.platform_api_key.create_failed",
|
|
"credential.auth_profile.create_failed",
|
|
"credential.platform_api_key.revoked",
|
|
"credential.platform_api_key.deleted",
|
|
] {
|
|
assert!(
|
|
actions.contains(&expected),
|
|
"missing audit action {expected}"
|
|
);
|
|
}
|
|
|
|
let audit_json = serde_json::to_string(&events).unwrap();
|
|
assert!(audit_json.contains(request_id));
|
|
assert!(audit_json.contains("11111111111111111111111111111111"));
|
|
assert!(audit_json.contains(auth_profile_id));
|
|
assert!(!audit_json.contains("audit-secret-canary"));
|
|
assert!(!audit_json.contains("audit-rotated-canary"));
|
|
assert!(!audit_json.contains(raw_key));
|
|
assert!(!audit_json.contains("secret_hash"));
|
|
assert!(!audit_json.contains("ciphertext"));
|
|
assert!(!audit_json.contains("Bearer "));
|
|
|
|
for event in &events {
|
|
let reason = event.payload["reason"]
|
|
.as_str()
|
|
.expect("credential audit events must contain a reason code");
|
|
assert!(!reason.is_empty());
|
|
assert!(reason.len() <= 128, "audit reason must remain bounded");
|
|
}
|
|
assert!(events.iter().any(|event| {
|
|
event.action == "credential.platform_api_key.create_failed"
|
|
&& event.payload["reason"] == "validation_error"
|
|
}));
|
|
assert!(events.iter().any(|event| {
|
|
event.action == "credential.platform_api_key.create_failed"
|
|
&& event.payload["reason"] == "platform_api_key_name_conflict"
|
|
}));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial]
|
|
async fn disabled_secret_fails_closed_for_reference_rotation_and_execution() {
|
|
let registry = test_registry().await;
|
|
let registry_control = registry.clone();
|
|
let storage_root = test_storage_root("credential_lifecycle_disabled_secret");
|
|
let (upstream_base_url, observed_authorizations) = spawn_auth_capture_upstream().await;
|
|
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
|
let client = authorized_client(&base_url).await;
|
|
|
|
let secret = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/secrets"))
|
|
.json(&json!({
|
|
"name": "disabled-token",
|
|
"kind": SecretKind::Token,
|
|
"value": { "token": "disabled-secret-canary" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let secret_id = secret["id"].as_str().unwrap();
|
|
|
|
let auth_profile = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/auth-profiles"))
|
|
.json(&json!({
|
|
"name": "disabled-profile",
|
|
"kind": "bearer",
|
|
"config": {
|
|
"bearer": {
|
|
"header_name": "Authorization",
|
|
"secret_id": secret_id
|
|
}
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let auth_profile_id = auth_profile["id"].as_str().unwrap();
|
|
|
|
let mut operation_payload = serde_json::to_value(test_operation_payload(
|
|
&upstream_base_url,
|
|
"disabled_secret_execution",
|
|
))
|
|
.unwrap();
|
|
operation_payload["execution_config"]["auth_profile_ref"] = json!(auth_profile_id);
|
|
let created_operation = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/operations"))
|
|
.json(&operation_payload)
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let operation_id = created_operation["operation_id"].as_str().unwrap();
|
|
|
|
sqlx::query("update secrets set status = 'disabled' where id = $1")
|
|
.bind(secret_id)
|
|
.execute(registry_control.pool())
|
|
.await
|
|
.unwrap();
|
|
|
|
let ref_disabled = client
|
|
.post(format!("{base_url}/auth-profiles"))
|
|
.json(&json!({
|
|
"name": "disabled-profile-new-ref",
|
|
"kind": "bearer",
|
|
"config": {
|
|
"bearer": {
|
|
"header_name": "Authorization",
|
|
"secret_id": secret_id
|
|
}
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(ref_disabled.status(), reqwest::StatusCode::CONFLICT);
|
|
let ref_disabled_body: Value =
|
|
serde_json::from_str(&ref_disabled.text().await.unwrap()).unwrap();
|
|
assert_eq!(ref_disabled_body["error"]["code"], "secret_not_active");
|
|
|
|
let rotate_disabled = client
|
|
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
|
.json(&json!({
|
|
"value": { "token": "should-not-rotate" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rotate_disabled.status(), reqwest::StatusCode::CONFLICT);
|
|
let rotate_body: Value = serde_json::from_str(&rotate_disabled.text().await.unwrap()).unwrap();
|
|
assert_eq!(rotate_body["error"]["code"], "secret_not_active");
|
|
|
|
let test_run = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
|
.json(&json!({
|
|
"version": 1,
|
|
"input": { "email": "disabled@example.com" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(test_run["ok"], false);
|
|
let body = serde_json::to_string(&test_run).unwrap();
|
|
assert!(!body.contains("disabled-secret-canary"));
|
|
assert!(observed_authorizations.lock().unwrap().is_empty());
|
|
|
|
let operation_get = client
|
|
.get(format!("{base_url}/operations/{operation_id}"))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let operation_etag = operation_get
|
|
.headers()
|
|
.get(reqwest::header::ETAG)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
.to_owned();
|
|
let publish_disabled = client
|
|
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
|
.header(reqwest::header::IF_MATCH, operation_etag)
|
|
.json(&json!({ "version": 1 }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(publish_disabled.status(), reqwest::StatusCode::CONFLICT);
|
|
let publish_body: Value =
|
|
serde_json::from_str(&publish_disabled.text().await.unwrap()).unwrap();
|
|
assert_eq!(publish_body["error"]["code"], "secret_not_active");
|
|
assert!(observed_authorizations.lock().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial]
|
|
async fn concurrent_secret_rotation_serializes_versions_without_storage_conflict() {
|
|
let registry = test_registry().await;
|
|
let storage_root = test_storage_root("credential_lifecycle_concurrent_rotation");
|
|
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
|
let client = authorized_client(&base_url).await;
|
|
|
|
let secret = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/secrets"))
|
|
.json(&json!({
|
|
"name": "concurrent-token",
|
|
"kind": SecretKind::Token,
|
|
"value": { "token": "initial-concurrent-token" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let secret_id = secret["id"].as_str().unwrap().to_owned();
|
|
|
|
let first = client
|
|
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
|
.json(&json!({ "value": { "token": "concurrent-token-a" } }))
|
|
.send();
|
|
let second = client
|
|
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
|
.json(&json!({ "value": { "token": "concurrent-token-b" } }))
|
|
.send();
|
|
let (first, second) = tokio::join!(first, second);
|
|
let first = assert_success_json(first.unwrap()).await;
|
|
let second = assert_success_json(second.unwrap()).await;
|
|
assert!(first["current_version"].as_u64().unwrap() >= 2);
|
|
assert!(second["current_version"].as_u64().unwrap() >= 2);
|
|
let current = assert_success_json(
|
|
client
|
|
.get(format!("{base_url}/secrets/{secret_id}"))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(current["current_version"], 3);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial]
|
|
async fn auth_profile_secret_rotation_changes_next_admin_test_execution() {
|
|
let registry = test_registry().await;
|
|
let storage_root = test_storage_root("credential_lifecycle_rotation");
|
|
let (upstream_base_url, observed_authorizations) = spawn_auth_capture_upstream().await;
|
|
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
|
let client = authorized_client(&base_url).await;
|
|
|
|
let secret = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/secrets"))
|
|
.json(&json!({
|
|
"name": "crm-rotating-token",
|
|
"kind": SecretKind::Token,
|
|
"value": { "token": "initial-rotation-token" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let secret_id = secret["id"].as_str().unwrap();
|
|
|
|
let auth_profile = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/auth-profiles"))
|
|
.json(&json!({
|
|
"name": "crm-rotating-bearer",
|
|
"kind": "bearer",
|
|
"config": {
|
|
"bearer": {
|
|
"header_name": "Authorization",
|
|
"secret_id": secret_id
|
|
}
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let auth_profile_id = auth_profile["id"].as_str().unwrap();
|
|
|
|
let mut operation_payload = serde_json::to_value(test_operation_payload(
|
|
&upstream_base_url,
|
|
"crm_auth_rotation",
|
|
))
|
|
.unwrap();
|
|
operation_payload["execution_config"]["auth_profile_ref"] = json!(auth_profile_id);
|
|
let created_operation = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/operations"))
|
|
.json(&operation_payload)
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
let operation_id = created_operation["operation_id"].as_str().unwrap();
|
|
|
|
let first_run = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
|
.json(&json!({
|
|
"version": 1,
|
|
"input": { "email": "first@example.com" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(first_run["ok"], true);
|
|
|
|
let rotated = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
|
.json(&json!({
|
|
"value": { "token": "rotated-rotation-token" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(rotated["current_version"], 2);
|
|
|
|
let second_run = assert_success_json(
|
|
client
|
|
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
|
.json(&json!({
|
|
"version": 1,
|
|
"input": { "email": "second@example.com" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(second_run["ok"], true);
|
|
|
|
let captured = observed_authorizations.lock().unwrap().clone();
|
|
assert_eq!(
|
|
captured,
|
|
vec![
|
|
Some("Bearer initial-rotation-token".to_owned()),
|
|
Some("Bearer rotated-rotation-token".to_owned())
|
|
]
|
|
);
|
|
|
|
let auth_profile_after = client
|
|
.get(format!("{base_url}/auth-profiles/{auth_profile_id}"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
assert!(auth_profile_after.contains(secret_id));
|
|
assert!(!auth_profile_after.contains("initial-rotation-token"));
|
|
assert!(!auth_profile_after.contains("rotated-rotation-token"));
|
|
}
|
|
|
|
async fn spawn_auth_capture_upstream() -> (String, Arc<Mutex<Vec<Option<String>>>>) {
|
|
let observed_authorizations = Arc::new(Mutex::new(Vec::new()));
|
|
let captured = Arc::clone(&observed_authorizations);
|
|
let app = Router::new().route(
|
|
"/crm/leads",
|
|
post(move |headers: HeaderMap| {
|
|
let captured = Arc::clone(&captured);
|
|
async move {
|
|
let authorization = headers
|
|
.get("authorization")
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_owned);
|
|
captured.lock().unwrap().push(authorization);
|
|
Json(json!({ "id": "lead_123" }))
|
|
}
|
|
}),
|
|
);
|
|
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).await.unwrap();
|
|
});
|
|
|
|
(format!("http://{address}"), observed_authorizations)
|
|
}
|
|
|
|
async fn assert_error_code(response: reqwest::Response, status: reqwest::StatusCode, code: &str) {
|
|
assert_eq!(response.status(), status);
|
|
let body = response.text().await.unwrap();
|
|
let json: Value = serde_json::from_str(&body).unwrap();
|
|
assert_eq!(json["error"]["code"], code, "unexpected error body: {body}");
|
|
}
|
|
|
|
async fn post_json(client: &reqwest::Client, url: String, payload: Value) -> Value {
|
|
assert_success_json(client.post(url).json(&payload).send().await.unwrap()).await
|
|
}
|
|
|
|
async fn create_agent(client: &reqwest::Client, base_url: impl AsRef<str>, slug: &str) -> String {
|
|
let base_url = base_url.as_ref();
|
|
post_json(
|
|
client,
|
|
format!("{base_url}/agents"),
|
|
json!({"slug": slug, "display_name": slug, "description": "Credential lifecycle test agent.", "instructions": {}, "tool_selection_policy": {}}),
|
|
)
|
|
.await["agent_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_owned()
|
|
}
|
|
|
|
async fn assert_post_error(
|
|
client: &reqwest::Client,
|
|
url: String,
|
|
payload: Value,
|
|
status: reqwest::StatusCode,
|
|
code: &str,
|
|
) {
|
|
assert_error_code(
|
|
client.post(url).json(&payload).send().await.unwrap(),
|
|
status,
|
|
code,
|
|
)
|
|
.await;
|
|
}
|