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
+1
View File
@@ -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
Generated
+103
View File
@@ -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"
+1
View File
@@ -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"] }
+6 -5
View File
@@ -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
+1
View File
@@ -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
+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,
+1
View File
@@ -47,3 +47,4 @@ define_id!(AgentId);
define_id!(InvitationId);
define_id!(PlatformApiKeyId);
define_id!(InvocationLogId);
define_id!(SecretId);
+3 -1
View File
@@ -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};
+42
View File
@@ -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<String>,
}
#[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<UserId>,
}
+4
View File
@@ -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")]
+11 -11
View File
@@ -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;
+36
View File
@@ -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,
+31 -2
View File
@@ -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,
+239 -12
View File
@@ -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<Vec<SecretRecord>, 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<Option<SecretRecord>, 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<SecretVersionRecord, RegistryError> {
let existing = self
.get_secret(request.workspace_id, request.secret_id)
.await?
.ok_or_else(|| RegistryError::SecretNotFound {
secret_id: request.secret_id.as_str().to_owned(),
})?;
let next_version = existing.secret.current_version + 1;
let mut tx = self.pool.begin().await?;
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<PlatformApiKeyRecord, Regi
})
}
fn map_secret_record(row: &PgRow) -> Result<SecretRecord, RegistryError> {
Ok(SecretRecord {
secret: Secret {
id: SecretId::new(row.try_get::<String, _>("id")?),
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
name: row.try_get("name")?,
kind: deserialize_enum_text(&row.try_get::<String, _>("kind")?, "kind")?,
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
current_version: from_db_version(
row.try_get::<i32, _>("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<InvocationLogRecord, RegistryError> {
Ok(InvocationLogRecord {
log: InvocationLog {
+1
View File
@@ -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}
+2 -2
View File
@@ -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
+5 -1
View File
@@ -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`