feat: add secret store foundation

This commit is contained in:
a.tolmachev
2026-04-06 01:13:10 +03:00
parent 420074f96a
commit d7e5ae95d6
23 changed files with 954 additions and 48 deletions
+99 -3
View File
@@ -27,6 +27,7 @@ use crate::{
list_grpc_services, list_operations, publish_operation, run_test, update_operation,
upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor,
},
secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret},
workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace},
},
state::AppState,
@@ -99,6 +100,12 @@ pub fn build_app(state: AppState) -> Router {
get(list_auth_profiles).post(create_auth_profile),
)
.route("/auth-profiles/{auth_profile_id}", get(get_auth_profile))
.route("/secrets", get(list_secrets).post(create_secret))
.route(
"/secrets/{secret_id}",
get(get_secret).delete(delete_secret),
)
.route("/secrets/{secret_id}/rotate", post(rotate_secret))
.route("/members", get(list_memberships))
.route(
"/members/{user_id}",
@@ -187,7 +194,7 @@ mod tests {
use crank_adapter_grpc::test_support as grpc_test_support;
use crank_core::{
DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
MembershipRole, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
MembershipRole, Protocol, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::PostgresRegistry;
@@ -200,6 +207,7 @@ mod tests {
use crate::{
app::build_app,
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
secret_crypto::SecretCrypto,
service::{AdminService, OperationPayload},
state::AppState,
};
@@ -209,6 +217,7 @@ mod tests {
const TEST_AUTH_PASSWORD: &str = "test-password";
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
const TEST_SESSION_SECRET: &str = "test-session-secret";
const TEST_MASTER_KEY: &str = "test-master-key";
struct TestServer {
base_url: String,
@@ -981,7 +990,12 @@ mod tests {
async fn seeds_demo_assets_for_live_ui() {
let registry = test_registry().await;
let storage_root = test_storage_root("demo_seed");
let service = AdminService::new(registry.clone(), storage_root, test_auth_settings());
let service = AdminService::new(
registry.clone(),
storage_root,
test_auth_settings(),
test_secret_crypto(),
);
service.bootstrap_admin_user().await.unwrap();
service.seed_demo_assets().await.unwrap();
@@ -1462,6 +1476,79 @@ mod tests {
assert_eq!(imported["import_mode"], "upsert");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn manages_workspace_secrets_without_exposing_plaintext() {
let registry = test_registry().await;
let storage_root = test_storage_root("secrets");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/secrets"))
.json(&json!({
"name": "crm-api-token",
"kind": SecretKind::Token,
"value": { "token": "super-secret-token" }
}))
.send()
.await
.unwrap();
let created = assert_success_json(created).await;
let secret_id = created["id"].as_str().unwrap().to_owned();
let listed = client
.get(format!("{base_url}/secrets"))
.send()
.await
.unwrap();
let listed = assert_success_json(listed).await;
let fetched = client
.get(format!("{base_url}/secrets/{secret_id}"))
.send()
.await
.unwrap();
let fetched = assert_success_json(fetched).await;
let rotated = client
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
.json(&json!({
"value": { "token": "rotated-token" }
}))
.send()
.await
.unwrap();
let rotated = assert_success_json(rotated).await;
let deleted = client
.delete(format!("{base_url}/secrets/{secret_id}"))
.send()
.await
.unwrap();
let deleted = assert_success_json(deleted).await;
let missing = client
.get(format!("{base_url}/secrets/{secret_id}"))
.send()
.await
.unwrap();
let missing_status = missing.status();
let missing = missing.json::<Value>().await.unwrap();
assert_eq!(created["name"], "crm-api-token");
assert_eq!(created["kind"], "token");
assert_eq!(created["current_version"], 1);
assert!(created.get("value").is_none());
assert_eq!(listed["items"].as_array().unwrap().len(), 1);
assert_eq!(fetched["id"], secret_id);
assert!(fetched.get("value").is_none());
assert_eq!(rotated["current_version"], 2);
assert_eq!(deleted["ok"], true);
assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND);
assert_eq!(missing["error"]["code"], "not_found");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn roundtrips_graphql_operation_through_yaml_upsert() {
@@ -1644,7 +1731,12 @@ mod tests {
fn build_test_app(registry: PostgresRegistry, storage_root: std::path::PathBuf) -> Router {
build_app(AppState {
service: AdminService::new(registry, storage_root, test_auth_settings()),
service: AdminService::new(
registry,
storage_root,
test_auth_settings(),
test_secret_crypto(),
),
})
}
@@ -1823,6 +1915,10 @@ mod tests {
}
}
fn test_secret_crypto() -> SecretCrypto {
SecretCrypto::new(TEST_MASTER_KEY).unwrap()
}
fn test_operation_payload(base_url: &str, name: &str) -> OperationPayload {
OperationPayload {
name: name.to_owned(),
+6
View File
@@ -139,6 +139,9 @@ impl From<RegistryError> for ApiError {
RegistryError::PlatformApiKeyNotFound { key_id } => {
Self::not_found(format!("platform api key {key_id} was not found"))
}
RegistryError::SecretNotFound { secret_id } => {
Self::not_found(format!("secret {secret_id} was not found"))
}
RegistryError::InvocationLogNotFound { log_id } => {
Self::not_found(format!("invocation log {log_id} was not found"))
}
@@ -171,6 +174,9 @@ impl From<RegistryError> for ApiError {
RegistryError::WorkspaceSlugAlreadyExists { slug } => {
Self::conflict(format!("workspace with slug {slug} already exists"))
}
RegistryError::SecretNameAlreadyExists { workspace_id, name } => Self::conflict(
format!("secret with name {name} already exists in workspace {workspace_id}"),
),
RegistryError::UserEmailAlreadyExists { email } => {
Self::conflict(format!("user with email {email} already exists"))
}
+4 -1
View File
@@ -2,6 +2,7 @@ mod app;
mod auth;
mod error;
mod routes;
mod secret_crypto;
mod service;
mod state;
mod storage;
@@ -15,6 +16,7 @@ use tracing::info;
use crate::{
app::build_app,
auth::{AuthSettings, BootstrapAdminConfig},
secret_crypto::SecretCrypto,
service::AdminService,
state::AppState,
};
@@ -51,7 +53,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.unwrap_or_else(|_| "Crank Owner".into()),
},
};
let service = AdminService::new(registry, storage_root, auth_settings);
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
let service = AdminService::new(registry, storage_root, auth_settings, secret_crypto);
service.bootstrap_admin_user().await?;
if env_flag("CRANK_DEMO_SEED") {
service.seed_demo_assets().await?;
+1
View File
@@ -4,6 +4,7 @@ pub mod auth;
pub mod auth_profiles;
pub mod observability;
pub mod operations;
pub mod secrets;
pub mod workspaces;
use axum::Json;
+98
View File
@@ -0,0 +1,98 @@
use axum::{
Extension, Json,
extract::{Path, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
auth::AuthenticatedSession,
error::ApiError,
service::{RotateSecretPayload, SecretPayload},
state::AppState,
};
#[derive(Deserialize)]
pub struct WorkspacePath {
pub workspace_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceSecretPath {
pub workspace_id: String,
pub secret_id: String,
}
pub async fn list_secrets(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_secrets(&path.workspace_id.as_str().into())
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_secret(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<SecretPayload>,
) -> Result<Json<Value>, ApiError> {
let secret = state
.service
.create_secret(
&path.workspace_id.as_str().into(),
Some(&session.user.id),
payload,
)
.await?;
Ok(Json(json!(secret)))
}
pub async fn get_secret(
Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let secret = state
.service
.get_secret(
&path.workspace_id.as_str().into(),
&path.secret_id.as_str().into(),
)
.await?;
Ok(Json(json!(secret)))
}
pub async fn rotate_secret(
Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<RotateSecretPayload>,
) -> Result<Json<Value>, ApiError> {
let secret = state
.service
.rotate_secret(
&path.workspace_id.as_str().into(),
&path.secret_id.as_str().into(),
Some(&session.user.id),
payload,
)
.await?;
Ok(Json(json!(secret)))
}
pub async fn delete_secret(
Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
state
.service
.delete_secret(
&path.workspace_id.as_str().into(),
&path.secret_id.as_str().into(),
)
.await?;
Ok(Json(json!({ "ok": true })))
}
+112
View File
@@ -0,0 +1,112 @@
use aes_gcm::{
Aes256Gcm, KeyInit, Nonce,
aead::{Aead, OsRng, rand_core::RngCore},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::error::ApiError;
#[derive(Clone)]
pub struct SecretCrypto {
cipher: Aes256Gcm,
key_version: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct CipherEnvelope {
nonce_b64: String,
ciphertext_b64: String,
}
impl SecretCrypto {
pub fn new(master_key: &str) -> Result<Self, ApiError> {
let trimmed = master_key.trim();
if trimmed.is_empty() {
return Err(ApiError::internal("CRANK_MASTER_KEY must not be empty"));
}
let digest = Sha256::digest(trimmed.as_bytes());
let cipher = Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| {
ApiError::internal(format!("failed to initialize secret crypto: {error}"))
})?;
Ok(Self {
cipher,
key_version: "v1".to_owned(),
})
}
pub fn key_version(&self) -> &str {
&self.key_version
}
pub fn encrypt(&self, value: &Value) -> Result<String, ApiError> {
let plaintext = serde_json::to_vec(value).map_err(|error| {
ApiError::internal(format!("failed to serialize secret value: {error}"))
})?;
let mut nonce_bytes = [0_u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = self
.cipher
.encrypt(nonce, plaintext.as_ref())
.map_err(|error| {
ApiError::internal(format!("failed to encrypt secret value: {error}"))
})?;
let envelope = CipherEnvelope {
nonce_b64: STANDARD.encode(nonce_bytes),
ciphertext_b64: STANDARD.encode(ciphertext),
};
serde_json::to_string(&envelope).map_err(|error| {
ApiError::internal(format!("failed to encode secret ciphertext: {error}"))
})
}
#[cfg(test)]
pub fn decrypt(&self, ciphertext: &str) -> Result<Value, ApiError> {
let envelope: CipherEnvelope = serde_json::from_str(ciphertext).map_err(|error| {
ApiError::internal(format!("failed to decode secret envelope: {error}"))
})?;
let nonce_bytes = STANDARD.decode(envelope.nonce_b64).map_err(|error| {
ApiError::internal(format!("failed to decode secret nonce: {error}"))
})?;
let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| {
ApiError::internal(format!("failed to decode secret payload: {error}"))
})?;
let plaintext = self
.cipher
.decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref())
.map_err(|error| {
ApiError::internal(format!("failed to decrypt secret value: {error}"))
})?;
serde_json::from_slice(&plaintext).map_err(|error| {
ApiError::internal(format!("failed to deserialize secret value: {error}"))
})
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::SecretCrypto;
#[test]
fn roundtrips_secret_payload() {
let crypto = SecretCrypto::new("test-master-key").unwrap();
let plaintext = json!({
"token": "top-secret",
"username": "demo"
});
let ciphertext = crypto.encrypt(&plaintext).unwrap();
let decrypted = crypto.decrypt(&ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
}
}
+147 -10
View File
@@ -12,21 +12,22 @@ use crank_core::{
InvitationId, InvitationStatus, InvitationToken, InvocationLevel, InvocationLog,
InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, OperationId,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus,
Protocol, SampleId, Samples, Target, UsagePeriod, UserSessionId, Workspace, WorkspaceId,
WorkspaceStatus,
Protocol, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, Target, UsagePeriod,
UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
use crank_registry::{
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PostgresRegistry,
PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord,
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, RotateSecretRequest,
SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint,
WorkspaceMembershipRecord, WorkspaceRecord,
};
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
use crank_schema::Schema;
@@ -43,6 +44,7 @@ use crate::{
hash_session_secret, verify_password,
},
error::ApiError,
secret_crypto::SecretCrypto,
storage::LocalArtifactStorage,
};
@@ -52,6 +54,7 @@ pub struct AdminService {
runtime: RuntimeExecutor,
storage: LocalArtifactStorage,
auth_settings: AuthSettings,
secret_crypto: SecretCrypto,
}
#[derive(Clone, Debug, Deserialize)]
@@ -134,6 +137,18 @@ pub struct AuthProfilePayload {
pub config: AuthConfig,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SecretPayload {
pub name: String,
pub kind: SecretKind,
pub value: Value,
}
#[derive(Clone, Debug, Deserialize)]
pub struct RotateSecretPayload {
pub value: Value,
}
#[derive(Clone, Debug, Deserialize)]
pub struct WorkspacePayload {
pub slug: String,
@@ -484,12 +499,14 @@ impl AdminService {
registry: PostgresRegistry,
storage_root: PathBuf,
auth_settings: AuthSettings,
secret_crypto: SecretCrypto,
) -> Self {
Self {
registry,
runtime: RuntimeExecutor::new(),
storage: LocalArtifactStorage::new(storage_root),
auth_settings,
secret_crypto,
}
}
@@ -1704,6 +1721,114 @@ impl AdminService {
Ok(self.registry.list_auth_profiles(workspace_id).await?)
}
#[instrument(skip(self))]
pub async fn list_secrets(&self, workspace_id: &WorkspaceId) -> Result<Vec<Secret>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
Ok(self
.registry
.list_secrets(workspace_id)
.await?
.into_iter()
.map(|record| record.secret)
.collect())
}
#[instrument(skip(self))]
pub async fn get_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<Secret, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
self.registry
.get_secret(workspace_id, secret_id)
.await?
.map(|record| record.secret)
.ok_or_else(|| {
ApiError::not_found(format!("secret {} was not found", secret_id.as_str()))
})
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))]
pub async fn create_secret(
&self,
workspace_id: &WorkspaceId,
created_by: Option<&UserId>,
payload: SecretPayload,
) -> Result<Secret, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
validate_secret_payload(&payload)?;
let now = now_string()?;
let secret = Secret {
id: SecretId::new(new_prefixed_id("secret")),
workspace_id: workspace_id.clone(),
name: payload.name.trim().to_owned(),
kind: payload.kind,
status: SecretStatus::Active,
current_version: 1,
created_at: now.clone(),
updated_at: now,
last_used_at: None,
};
let ciphertext = self.secret_crypto.encrypt(&payload.value)?;
self.registry
.create_secret(CreateSecretRequest {
secret: &secret,
ciphertext: &ciphertext,
key_version: self.secret_crypto.key_version(),
created_by,
})
.await?;
info!(secret_id = %secret.id.as_str(), "secret created");
Ok(secret)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
pub async fn rotate_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
created_by: Option<&UserId>,
payload: RotateSecretPayload,
) -> Result<Secret, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
if payload.value.is_null() {
return Err(ApiError::validation("secret value must not be null"));
}
let now = now_string()?;
let ciphertext = self.secret_crypto.encrypt(&payload.value)?;
self.registry
.rotate_secret(RotateSecretRequest {
workspace_id,
secret_id,
ciphertext: &ciphertext,
key_version: self.secret_crypto.key_version(),
created_at: &now,
updated_at: &now,
created_by,
})
.await?;
info!(secret_id = %secret_id.as_str(), "secret rotated");
self.get_secret(workspace_id, secret_id).await
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
pub async fn delete_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<(), ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
self.registry.delete_secret(workspace_id, secret_id).await?;
info!(secret_id = %secret_id.as_str(), "secret deleted");
Ok(())
}
#[instrument(skip(self))]
pub async fn list_agents(
&self,
@@ -3249,6 +3374,18 @@ fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(),
Err(ApiError::validation("auth kind and config must match"))
}
fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> {
if payload.name.trim().is_empty() {
return Err(ApiError::validation("secret name must not be empty"));
}
if payload.value.is_null() {
return Err(ApiError::validation("secret value must not be null"));
}
Ok(())
}
fn latest_sample_ref(
samples: &[OperationSampleMetadata],
sample_kind: SampleKind,