diff --git a/.env.example b/.env.example index c2719c1..e08f38f 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ CRANK_MCP_BIND=0.0.0.0:3002 CRANK_MCP_REFRESH_MS=5000 CRANK_LOG_LEVEL=info CRANK_SECRET_PROVIDER=env +CRANK_MASTER_KEY=change-me-master-key CRANK_SESSION_SECRET=change-me-session-secret CRANK_PASSWORD_PEPPER=change-me-password-pepper CRANK_SESSION_TTL_HOURS=24 diff --git a/Cargo.lock b/Cargo.lock index 402bd99..3410d48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "admin-api" version = "0.1.0" dependencies = [ + "aes-gcm", "argon2", "axum", "axum-extra", @@ -33,6 +34,41 @@ dependencies = [ "uuid", ] +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -249,6 +285,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -457,9 +503,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "deranged" version = "0.5.8" @@ -713,6 +769,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "h2" version = "0.4.13" @@ -1038,6 +1104,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1256,6 +1331,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "ordered-float" version = "2.10.1" @@ -1360,6 +1441,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2626,6 +2719,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index d3af950..beb18c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ rust-version = "1.85" version = "0.1.0" [workspace.dependencies] +aes-gcm = "0.10" argon2 = "0.5" axum = "0.8" axum-extra = { version = "0.10", features = ["cookie"] } diff --git a/TASKS.md b/TASKS.md index 2c4d8b6..f3c76f0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,18 +2,19 @@ ## Current -### `feat/secrets-auth-plan` +### `feat/secret-store-foundation` Status: completed DoD: -- Docs describe the target secret store and auth profile model -- Backend, runtime, and UI gaps are captured as vertical slices -- TASKS and implementation plan reflect the new sequence +- Workspace-scoped secret store is persisted in PostgreSQL +- Secret values are encrypted with `CRANK_MASTER_KEY` +- Admin API exposes create/list/get/rotate/delete secret endpoints +- Integration tests cover secret CRUD and rotation ## Next -- `feat/secret-store-foundation` +- `feat/auth-profile-secret-resolution` ## Backlog diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index 9897ec2..1cab8b7 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true version.workspace = true [dependencies] +aes-gcm.workspace = true argon2.workspace = true axum.workspace = true axum-extra.workspace = true diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index d5081cf..3337f38 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -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::().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(), diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 9018099..3ffcde9 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -139,6 +139,9 @@ impl From 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 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")) } diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index 2eb1ab1..1f81762 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -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> { .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?; diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs index 2f2828d..f5b1e3b 100644 --- a/apps/admin-api/src/routes.rs +++ b/apps/admin-api/src/routes.rs @@ -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; diff --git a/apps/admin-api/src/routes/secrets.rs b/apps/admin-api/src/routes/secrets.rs new file mode 100644 index 0000000..4bb1817 --- /dev/null +++ b/apps/admin-api/src/routes/secrets.rs @@ -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, + State(state): State, +) -> Result, 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, + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, 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, + State(state): State, +) -> Result, 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, + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, 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, + State(state): State, +) -> Result, ApiError> { + state + .service + .delete_secret( + &path.workspace_id.as_str().into(), + &path.secret_id.as_str().into(), + ) + .await?; + Ok(Json(json!({ "ok": true }))) +} diff --git a/apps/admin-api/src/secret_crypto.rs b/apps/admin-api/src/secret_crypto.rs new file mode 100644 index 0000000..0316d68 --- /dev/null +++ b/apps/admin-api/src/secret_crypto.rs @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index c2a6d8e..607bbf9 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -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, 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 { + 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 { + 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 { + 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, diff --git a/crates/crank-core/src/ids.rs b/crates/crank-core/src/ids.rs index 604e255..be5a2b4 100644 --- a/crates/crank-core/src/ids.rs +++ b/crates/crank-core/src/ids.rs @@ -47,3 +47,4 @@ define_id!(AgentId); define_id!(InvitationId); define_id!(PlatformApiKeyId); define_id!(InvocationLogId); +define_id!(SecretId); diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index 23cf570..688f7e3 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod ids; pub mod observability; pub mod operation; pub mod protocol; +pub mod secret; pub mod workspace; pub use access::{ @@ -18,7 +19,7 @@ pub use auth::{ }; pub use ids::{ AgentId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId, - PlatformApiKeyId, SampleId, ToolId, UserId, UserSessionId, WorkspaceId, + PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId, WorkspaceId, }; pub use observability::{ InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup, @@ -29,4 +30,5 @@ pub use operation::{ RetryPolicy, Samples, Target, ToolDescription, ToolExample, }; pub use protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol}; +pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion}; pub use workspace::{Workspace, WorkspaceStatus}; diff --git a/crates/crank-core/src/secret.rs b/crates/crank-core/src/secret.rs new file mode 100644 index 0000000..375223f --- /dev/null +++ b/crates/crank-core/src/secret.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +use crate::ids::{SecretId, UserId, WorkspaceId}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SecretKind { + Token, + UsernamePassword, + Header, + Generic, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SecretStatus { + Active, + Disabled, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Secret { + pub id: SecretId, + pub workspace_id: WorkspaceId, + pub name: String, + pub kind: SecretKind, + pub status: SecretStatus, + pub current_version: u32, + pub created_at: String, + pub updated_at: String, + pub last_used_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecretVersion { + pub secret_id: SecretId, + pub version: u32, + pub ciphertext: String, + pub key_version: String, + pub created_at: String, + pub created_by: Option, +} diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index 8089561..2df780f 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -23,6 +23,10 @@ pub enum RegistryError { InvitationNotFound { invitation_id: String }, #[error("platform api key {key_id} was not found")] PlatformApiKeyNotFound { key_id: String }, + #[error("secret {secret_id} was not found")] + SecretNotFound { secret_id: String }, + #[error("secret with name {name} already exists in workspace {workspace_id}")] + SecretNameAlreadyExists { workspace_id: String, name: String }, #[error("invocation log {log_id} was not found")] InvocationLogNotFound { log_id: String }, #[error("agent {agent_id} was not found")] diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index fa9a8d3..773ecc3 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -6,16 +6,16 @@ mod postgres; pub use error::RegistryError; pub use model::{ AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, CreateInvitationRequest, - CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, - CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, - InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, - OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary, - OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, - PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, - UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, - WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, - YamlImportJobId, YamlImportJobStatus, + CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, + CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, + DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, + MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, + OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, + PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind, + SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, + SaveSampleMetadataRequest, SecretRecord, SecretVersionRecord, SessionRecord, + UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, + UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, + WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, }; pub use postgres::PostgresRegistry; diff --git a/crates/crank-registry/src/migrations.rs b/crates/crank-registry/src/migrations.rs index 8560eb7..d26b8b2 100644 --- a/crates/crank-registry/src/migrations.rs +++ b/crates/crank-registry/src/migrations.rs @@ -328,6 +328,42 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + query( + "create table if not exists secrets ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + name text not null, + kind text not null, + status text not null, + current_version integer not null, + last_used_at timestamptz null, + created_at timestamptz not null, + updated_at timestamptz not null + )", + ) + .execute(pool) + .await?; + + query( + "create unique index if not exists secrets_workspace_name_idx on secrets(workspace_id, name)", + ) + .execute(pool) + .await?; + + query( + "create table if not exists secret_versions ( + secret_id text not null references secrets(id) on delete cascade, + version integer not null, + ciphertext text not null, + key_version text not null, + created_at timestamptz not null, + created_by text null references users(id) on delete set null, + primary key (secret_id, version) + )", + ) + .execute(pool) + .await?; + query( "create table if not exists yaml_import_jobs ( id text primary key, diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index 9b1b9c4..d578871 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -1,8 +1,8 @@ use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId, ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, - Operation, OperationId, OperationStatus, PlatformApiKey, Protocol, SampleId, UsagePeriod, - UsageRollup, User, UserSessionId, Workspace, WorkspaceId, + Operation, OperationId, OperationStatus, PlatformApiKey, Protocol, SampleId, Secret, SecretId, + SecretVersion, UsagePeriod, UsageRollup, User, UserSessionId, Workspace, WorkspaceId, }; use crank_mapping::MappingSet; use crank_schema::Schema; @@ -85,6 +85,16 @@ pub struct PlatformApiKeyRecord { pub api_key: PlatformApiKey, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SecretRecord { + pub secret: Secret, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SecretVersionRecord { + pub secret_version: SecretVersion, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct InvocationLogRecord { pub log: InvocationLog, @@ -403,6 +413,25 @@ pub struct CreatePlatformApiKeyRequest<'a> { pub secret_hash: &'a str, } +#[derive(Clone, Debug, PartialEq)] +pub struct CreateSecretRequest<'a> { + pub secret: &'a Secret, + pub ciphertext: &'a str, + pub key_version: &'a str, + pub created_by: Option<&'a crank_core::UserId>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RotateSecretRequest<'a> { + pub workspace_id: &'a WorkspaceId, + pub secret_id: &'a SecretId, + pub ciphertext: &'a str, + pub key_version: &'a str, + pub created_at: &'a str, + pub updated_at: &'a str, + pub created_by: Option<&'a crank_core::UserId>, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SaveSampleMetadataRequest<'a> { pub sample: &'a OperationSampleMetadata, diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index 06d5eb7..ca6d103 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -1,8 +1,9 @@ use crank_core::{ AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, GraphqlOperationType, HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole, - OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Target, - UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId, + OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Secret, + SecretId, SecretVersion, Target, UsageRollup, User, UserId, UserSessionId, Workspace, + WorkspaceId, }; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; @@ -18,16 +19,17 @@ use crate::{ model::{ AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, - CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, - DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, - MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, - OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, - PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown, - UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, - WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, - YamlImportJobStatus, + CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, + CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord, InvocationLogRecord, + ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata, + OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, + PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation, + RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest, + SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SecretRecord, + SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, + UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, + WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, + YamlImportJobId, YamlImportJobStatus, }, }; @@ -652,6 +654,212 @@ impl PostgresRegistry { row.as_ref().map(map_platform_api_key_record).transpose() } + pub async fn list_secrets( + &self, + workspace_id: &WorkspaceId, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + id, + workspace_id, + name, + kind, + status, + current_version, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at, + to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_used_at + from secrets + where workspace_id = $1 + order by name asc", + ) + .bind(workspace_id.as_str()) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_secret_record).collect() + } + + pub async fn get_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + id, + workspace_id, + name, + kind, + status, + current_version, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at, + to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_used_at + from secrets + where workspace_id = $1 and id = $2", + ) + .bind(workspace_id.as_str()) + .bind(secret_id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.as_ref().map(map_secret_record).transpose() + } + + pub async fn create_secret( + &self, + request: CreateSecretRequest<'_>, + ) -> Result<(), RegistryError> { + let mut tx = self.pool.begin().await?; + let result = sqlx::query( + "insert into secrets ( + id, + workspace_id, + name, + kind, + status, + current_version, + last_used_at, + created_at, + updated_at + ) values ( + $1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz, $9::timestamptz + )", + ) + .bind(request.secret.id.as_str()) + .bind(request.secret.workspace_id.as_str()) + .bind(&request.secret.name) + .bind(serialize_enum_text(&request.secret.kind, "kind")?) + .bind(serialize_enum_text(&request.secret.status, "status")?) + .bind(to_db_version(request.secret.current_version)) + .bind(request.secret.last_used_at.as_deref()) + .bind(&request.secret.created_at) + .bind(&request.secret.updated_at) + .execute(&mut *tx) + .await; + + match result { + Ok(_) => { + sqlx::query( + "insert into secret_versions ( + secret_id, + version, + ciphertext, + key_version, + created_at, + created_by + ) values ( + $1, $2, $3, $4, $5::timestamptz, $6 + )", + ) + .bind(request.secret.id.as_str()) + .bind(to_db_version(request.secret.current_version)) + .bind(request.ciphertext) + .bind(request.key_version) + .bind(&request.secret.created_at) + .bind(request.created_by.map(|value| value.as_str())) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + Err(sqlx::Error::Database(error)) + if error.constraint() == Some("secrets_workspace_name_idx") => + { + Err(RegistryError::SecretNameAlreadyExists { + workspace_id: request.secret.workspace_id.as_str().to_owned(), + name: request.secret.name.clone(), + }) + } + Err(error) => Err(RegistryError::Storage(error)), + } + } + + pub async fn rotate_secret( + &self, + request: RotateSecretRequest<'_>, + ) -> Result { + 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?; + sqlx::query( + "insert into secret_versions ( + secret_id, + version, + ciphertext, + key_version, + created_at, + created_by + ) values ( + $1, $2, $3, $4, $5::timestamptz, $6 + )", + ) + .bind(request.secret_id.as_str()) + .bind(to_db_version(next_version)) + .bind(request.ciphertext) + .bind(request.key_version) + .bind(request.created_at) + .bind(request.created_by.map(|value| value.as_str())) + .execute(&mut *tx) + .await?; + + sqlx::query( + "update secrets + set current_version = $3, + updated_at = $4::timestamptz + where workspace_id = $1 and id = $2", + ) + .bind(request.workspace_id.as_str()) + .bind(request.secret_id.as_str()) + .bind(to_db_version(next_version)) + .bind(request.updated_at) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(SecretVersionRecord { + secret_version: SecretVersion { + secret_id: request.secret_id.clone(), + version: next_version, + ciphertext: request.ciphertext.to_owned(), + key_version: request.key_version.to_owned(), + created_at: request.created_at.to_owned(), + created_by: request.created_by.cloned(), + }, + }) + } + + pub async fn delete_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + ) -> Result<(), RegistryError> { + 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) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::SecretNotFound { + secret_id: secret_id.as_str().to_owned(), + }); + } + + Ok(()) + } + pub async fn create_platform_api_key( &self, request: CreatePlatformApiKeyRequest<'_>, @@ -3088,6 +3296,25 @@ fn map_platform_api_key_record(row: &PgRow) -> Result Result { + Ok(SecretRecord { + secret: Secret { + id: SecretId::new(row.try_get::("id")?), + workspace_id: WorkspaceId::new(row.try_get::("workspace_id")?), + name: row.try_get("name")?, + kind: deserialize_enum_text(&row.try_get::("kind")?, "kind")?, + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + current_version: from_db_version( + row.try_get::("current_version")?, + "current_version", + )?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + last_used_at: row.try_get("last_used_at")?, + }, + }) +} + fn map_invocation_log_record(row: &PgRow) -> Result { Ok(InvocationLogRecord { log: InvocationLog { diff --git a/docker-compose.yml b/docker-compose.yml index 72bb869..24760f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: CRANK_ADMIN_BIND: ${CRANK_ADMIN_BIND:-0.0.0.0:3001} CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-info} CRANK_SECRET_PROVIDER: ${CRANK_SECRET_PROVIDER:-env} + CRANK_MASTER_KEY: ${CRANK_MASTER_KEY} CRANK_PUBLIC_BASE_URL: ${CRANK_PUBLIC_BASE_URL:-http://localhost:3000} CRANK_SESSION_SECRET: ${CRANK_SESSION_SECRET} CRANK_PASSWORD_PEPPER: ${CRANK_PASSWORD_PEPPER} diff --git a/docs/admin-api.md b/docs/admin-api.md index 32b5ee4..63ea0fc 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -131,10 +131,10 @@ Контракт: -- `POST /secrets` принимает metadata и plaintext value, но plaintext возвращается только в create/rotate request path и не выдается повторно; +- `POST /secrets` принимает metadata и plaintext value, но create-response возвращает только metadata; - `GET /secrets` и `GET /secrets/{secret_id}` возвращают только metadata, `kind`, `status`, `current_version`, `created_at`, `updated_at`, `last_used_at` при наличии; - `POST /secrets/{secret_id}/rotate` создает новую secret version; -- `DELETE /secrets/{secret_id}` запрещен, если secret используется опубликованными auth profiles или operations; +- `DELETE /secrets/{secret_id}` в secret foundation удаляет secret без reference checks; валидация ссылок добавляется в `feat/auth-profile-secret-resolution`; - `AuthProfile.config` хранит ссылки на `secret_id`, а не placeholder-строки `${secrets.*}`. ### 5.6. Agents diff --git a/docs/runtime-config.md b/docs/runtime-config.md index 3785ae6..447e028 100644 --- a/docs/runtime-config.md +++ b/docs/runtime-config.md @@ -65,13 +65,13 @@ var/crank/ - `CRANK_MCP_REFRESH_MS` - `CRANK_LOG_LEVEL` - `CRANK_SECRET_PROVIDER` +- `CRANK_MASTER_KEY` - `CRANK_PUBLIC_BASE_URL` - `CRANK_MCP_PUBLIC_URL` Опционально: - `CRANK_ADMIN_TOKEN` -- `CRANK_MASTER_KEY` - `CRANK_DEMO_SEED` Стартовое значение для refresh published tools: @@ -131,6 +131,10 @@ Demo/deployment: - `CRANK_BOOTSTRAP_ADMIN_PASSWORD` - `CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME` +Для secret store foundation нужен: + +- `CRANK_MASTER_KEY` + Для опционального demo-seed: - `CRANK_DEMO_SEED=true`