feat: add secret store foundation
This commit is contained in:
@@ -12,6 +12,7 @@ CRANK_MCP_BIND=0.0.0.0:3002
|
|||||||
CRANK_MCP_REFRESH_MS=5000
|
CRANK_MCP_REFRESH_MS=5000
|
||||||
CRANK_LOG_LEVEL=info
|
CRANK_LOG_LEVEL=info
|
||||||
CRANK_SECRET_PROVIDER=env
|
CRANK_SECRET_PROVIDER=env
|
||||||
|
CRANK_MASTER_KEY=change-me-master-key
|
||||||
CRANK_SESSION_SECRET=change-me-session-secret
|
CRANK_SESSION_SECRET=change-me-session-secret
|
||||||
CRANK_PASSWORD_PEPPER=change-me-password-pepper
|
CRANK_PASSWORD_PEPPER=change-me-password-pepper
|
||||||
CRANK_SESSION_TTL_HOURS=24
|
CRANK_SESSION_TTL_HOURS=24
|
||||||
|
|||||||
Generated
+103
@@ -6,6 +6,7 @@ version = 4
|
|||||||
name = "admin-api"
|
name = "admin-api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
@@ -33,6 +34,41 @@ dependencies = [
|
|||||||
"uuid",
|
"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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -249,6 +285,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
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]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -457,9 +503,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -713,6 +769,16 @@ dependencies = [
|
|||||||
"wasip3",
|
"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]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@@ -1038,6 +1104,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -1256,6 +1331,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-float"
|
name = "ordered-float"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -1360,6 +1441,18 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2626,6 +2719,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "unsafe-libyaml"
|
name = "unsafe-libyaml"
|
||||||
version = "0.2.11"
|
version = "0.2.11"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ rust-version = "1.85"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
aes-gcm = "0.10"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||||
|
|||||||
@@ -2,18 +2,19 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/secrets-auth-plan`
|
### `feat/secret-store-foundation`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- Docs describe the target secret store and auth profile model
|
- Workspace-scoped secret store is persisted in PostgreSQL
|
||||||
- Backend, runtime, and UI gaps are captured as vertical slices
|
- Secret values are encrypted with `CRANK_MASTER_KEY`
|
||||||
- TASKS and implementation plan reflect the new sequence
|
- Admin API exposes create/list/get/rotate/delete secret endpoints
|
||||||
|
- Integration tests cover secret CRUD and rotation
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/secret-store-foundation`
|
- `feat/auth-profile-secret-resolution`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ rust-version.workspace = true
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
aes-gcm.workspace = true
|
||||||
argon2.workspace = true
|
argon2.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
axum-extra.workspace = true
|
axum-extra.workspace = true
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use crate::{
|
|||||||
list_grpc_services, list_operations, publish_operation, run_test, update_operation,
|
list_grpc_services, list_operations, publish_operation, run_test, update_operation,
|
||||||
upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor,
|
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},
|
workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace},
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
@@ -99,6 +100,12 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
get(list_auth_profiles).post(create_auth_profile),
|
get(list_auth_profiles).post(create_auth_profile),
|
||||||
)
|
)
|
||||||
.route("/auth-profiles/{auth_profile_id}", get(get_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", get(list_memberships))
|
||||||
.route(
|
.route(
|
||||||
"/members/{user_id}",
|
"/members/{user_id}",
|
||||||
@@ -187,7 +194,7 @@ mod tests {
|
|||||||
use crank_adapter_grpc::test_support as grpc_test_support;
|
use crank_adapter_grpc::test_support as grpc_test_support;
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
|
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_mapping::{MappingRule, MappingSet};
|
||||||
use crank_registry::PostgresRegistry;
|
use crank_registry::PostgresRegistry;
|
||||||
@@ -200,6 +207,7 @@ mod tests {
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::build_app,
|
app::build_app,
|
||||||
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
||||||
|
secret_crypto::SecretCrypto,
|
||||||
service::{AdminService, OperationPayload},
|
service::{AdminService, OperationPayload},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -209,6 +217,7 @@ mod tests {
|
|||||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||||
|
const TEST_MASTER_KEY: &str = "test-master-key";
|
||||||
|
|
||||||
struct TestServer {
|
struct TestServer {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
@@ -981,7 +990,12 @@ mod tests {
|
|||||||
async fn seeds_demo_assets_for_live_ui() {
|
async fn seeds_demo_assets_for_live_ui() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
let storage_root = test_storage_root("demo_seed");
|
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.bootstrap_admin_user().await.unwrap();
|
||||||
service.seed_demo_assets().await.unwrap();
|
service.seed_demo_assets().await.unwrap();
|
||||||
@@ -1462,6 +1476,79 @@ mod tests {
|
|||||||
assert_eq!(imported["import_mode"], "upsert");
|
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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn roundtrips_graphql_operation_through_yaml_upsert() {
|
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 {
|
fn build_test_app(registry: PostgresRegistry, storage_root: std::path::PathBuf) -> Router {
|
||||||
build_app(AppState {
|
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 {
|
fn test_operation_payload(base_url: &str, name: &str) -> OperationPayload {
|
||||||
OperationPayload {
|
OperationPayload {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ impl From<RegistryError> for ApiError {
|
|||||||
RegistryError::PlatformApiKeyNotFound { key_id } => {
|
RegistryError::PlatformApiKeyNotFound { key_id } => {
|
||||||
Self::not_found(format!("platform api key {key_id} was not found"))
|
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 } => {
|
RegistryError::InvocationLogNotFound { log_id } => {
|
||||||
Self::not_found(format!("invocation log {log_id} was not found"))
|
Self::not_found(format!("invocation log {log_id} was not found"))
|
||||||
}
|
}
|
||||||
@@ -171,6 +174,9 @@ impl From<RegistryError> for ApiError {
|
|||||||
RegistryError::WorkspaceSlugAlreadyExists { slug } => {
|
RegistryError::WorkspaceSlugAlreadyExists { slug } => {
|
||||||
Self::conflict(format!("workspace with slug {slug} already exists"))
|
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 } => {
|
RegistryError::UserEmailAlreadyExists { email } => {
|
||||||
Self::conflict(format!("user with email {email} already exists"))
|
Self::conflict(format!("user with email {email} already exists"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod app;
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod error;
|
mod error;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod secret_crypto;
|
||||||
mod service;
|
mod service;
|
||||||
mod state;
|
mod state;
|
||||||
mod storage;
|
mod storage;
|
||||||
@@ -15,6 +16,7 @@ use tracing::info;
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::build_app,
|
app::build_app,
|
||||||
auth::{AuthSettings, BootstrapAdminConfig},
|
auth::{AuthSettings, BootstrapAdminConfig},
|
||||||
|
secret_crypto::SecretCrypto,
|
||||||
service::AdminService,
|
service::AdminService,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -51,7 +53,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.unwrap_or_else(|_| "Crank Owner".into()),
|
.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?;
|
service.bootstrap_admin_user().await?;
|
||||||
if env_flag("CRANK_DEMO_SEED") {
|
if env_flag("CRANK_DEMO_SEED") {
|
||||||
service.seed_demo_assets().await?;
|
service.seed_demo_assets().await?;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod auth;
|
|||||||
pub mod auth_profiles;
|
pub mod auth_profiles;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod operations;
|
pub mod operations;
|
||||||
|
pub mod secrets;
|
||||||
pub mod workspaces;
|
pub mod workspaces;
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
|||||||
@@ -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 })))
|
||||||
|
}
|
||||||
@@ -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
@@ -12,21 +12,22 @@ use crank_core::{
|
|||||||
InvitationId, InvitationStatus, InvitationToken, InvocationLevel, InvocationLog,
|
InvitationId, InvitationStatus, InvitationToken, InvocationLevel, InvocationLog,
|
||||||
InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, OperationId,
|
InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, OperationId,
|
||||||
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus,
|
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus,
|
||||||
Protocol, SampleId, Samples, Target, UsagePeriod, UserSessionId, Workspace, WorkspaceId,
|
Protocol, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, Target, UsagePeriod,
|
||||||
WorkspaceStatus,
|
UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus,
|
||||||
};
|
};
|
||||||
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
|
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
|
||||||
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
|
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
|
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
|
||||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
|
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||||
CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
|
CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord,
|
||||||
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||||
OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PostgresRegistry,
|
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
|
||||||
PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
|
PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, RotateSecretRequest,
|
||||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||||
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
|
||||||
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
|
UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint,
|
||||||
|
WorkspaceMembershipRecord, WorkspaceRecord,
|
||||||
};
|
};
|
||||||
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
|
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
|
||||||
use crank_schema::Schema;
|
use crank_schema::Schema;
|
||||||
@@ -43,6 +44,7 @@ use crate::{
|
|||||||
hash_session_secret, verify_password,
|
hash_session_secret, verify_password,
|
||||||
},
|
},
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
|
secret_crypto::SecretCrypto,
|
||||||
storage::LocalArtifactStorage,
|
storage::LocalArtifactStorage,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ pub struct AdminService {
|
|||||||
runtime: RuntimeExecutor,
|
runtime: RuntimeExecutor,
|
||||||
storage: LocalArtifactStorage,
|
storage: LocalArtifactStorage,
|
||||||
auth_settings: AuthSettings,
|
auth_settings: AuthSettings,
|
||||||
|
secret_crypto: SecretCrypto,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
@@ -134,6 +137,18 @@ pub struct AuthProfilePayload {
|
|||||||
pub config: AuthConfig,
|
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)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct WorkspacePayload {
|
pub struct WorkspacePayload {
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
@@ -484,12 +499,14 @@ impl AdminService {
|
|||||||
registry: PostgresRegistry,
|
registry: PostgresRegistry,
|
||||||
storage_root: PathBuf,
|
storage_root: PathBuf,
|
||||||
auth_settings: AuthSettings,
|
auth_settings: AuthSettings,
|
||||||
|
secret_crypto: SecretCrypto,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
registry,
|
registry,
|
||||||
runtime: RuntimeExecutor::new(),
|
runtime: RuntimeExecutor::new(),
|
||||||
storage: LocalArtifactStorage::new(storage_root),
|
storage: LocalArtifactStorage::new(storage_root),
|
||||||
auth_settings,
|
auth_settings,
|
||||||
|
secret_crypto,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1704,6 +1721,114 @@ impl AdminService {
|
|||||||
Ok(self.registry.list_auth_profiles(workspace_id).await?)
|
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))]
|
#[instrument(skip(self))]
|
||||||
pub async fn list_agents(
|
pub async fn list_agents(
|
||||||
&self,
|
&self,
|
||||||
@@ -3249,6 +3374,18 @@ fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(),
|
|||||||
Err(ApiError::validation("auth kind and config must match"))
|
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(
|
fn latest_sample_ref(
|
||||||
samples: &[OperationSampleMetadata],
|
samples: &[OperationSampleMetadata],
|
||||||
sample_kind: SampleKind,
|
sample_kind: SampleKind,
|
||||||
|
|||||||
@@ -47,3 +47,4 @@ define_id!(AgentId);
|
|||||||
define_id!(InvitationId);
|
define_id!(InvitationId);
|
||||||
define_id!(PlatformApiKeyId);
|
define_id!(PlatformApiKeyId);
|
||||||
define_id!(InvocationLogId);
|
define_id!(InvocationLogId);
|
||||||
|
define_id!(SecretId);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod ids;
|
|||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod operation;
|
pub mod operation;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
pub mod secret;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
pub use access::{
|
pub use access::{
|
||||||
@@ -18,7 +19,7 @@ pub use auth::{
|
|||||||
};
|
};
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AgentId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId,
|
AgentId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId,
|
||||||
PlatformApiKeyId, SampleId, ToolId, UserId, UserSessionId, WorkspaceId,
|
PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId, WorkspaceId,
|
||||||
};
|
};
|
||||||
pub use observability::{
|
pub use observability::{
|
||||||
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup,
|
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup,
|
||||||
@@ -29,4 +30,5 @@ pub use operation::{
|
|||||||
RetryPolicy, Samples, Target, ToolDescription, ToolExample,
|
RetryPolicy, Samples, Target, ToolDescription, ToolExample,
|
||||||
};
|
};
|
||||||
pub use protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol};
|
pub use protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol};
|
||||||
|
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||||
pub use workspace::{Workspace, WorkspaceStatus};
|
pub use workspace::{Workspace, WorkspaceStatus};
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ pub enum RegistryError {
|
|||||||
InvitationNotFound { invitation_id: String },
|
InvitationNotFound { invitation_id: String },
|
||||||
#[error("platform api key {key_id} was not found")]
|
#[error("platform api key {key_id} was not found")]
|
||||||
PlatformApiKeyNotFound { key_id: String },
|
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")]
|
#[error("invocation log {log_id} was not found")]
|
||||||
InvocationLogNotFound { log_id: String },
|
InvocationLogNotFound { log_id: String },
|
||||||
#[error("agent {agent_id} was not found")]
|
#[error("agent {agent_id} was not found")]
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ mod postgres;
|
|||||||
pub use error::RegistryError;
|
pub use error::RegistryError;
|
||||||
pub use model::{
|
pub use model::{
|
||||||
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, CreateInvitationRequest,
|
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, CreateInvitationRequest,
|
||||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
|
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata,
|
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
|
||||||
InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord,
|
DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
|
||||||
OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary,
|
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
||||||
OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest,
|
OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest,
|
||||||
PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
|
PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
|
||||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
|
SaveSampleMetadataRequest, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||||
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
||||||
WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||||
YamlImportJobId, YamlImportJobStatus,
|
WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||||
};
|
};
|
||||||
pub use postgres::PostgresRegistry;
|
pub use postgres::PostgresRegistry;
|
||||||
|
|||||||
@@ -328,6 +328,42 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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(
|
query(
|
||||||
"create table if not exists yaml_import_jobs (
|
"create table if not exists yaml_import_jobs (
|
||||||
id text primary key,
|
id text primary key,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crank_core::{
|
use crank_core::{
|
||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId,
|
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId,
|
||||||
ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole,
|
ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole,
|
||||||
Operation, OperationId, OperationStatus, PlatformApiKey, Protocol, SampleId, UsagePeriod,
|
Operation, OperationId, OperationStatus, PlatformApiKey, Protocol, SampleId, Secret, SecretId,
|
||||||
UsageRollup, User, UserSessionId, Workspace, WorkspaceId,
|
SecretVersion, UsagePeriod, UsageRollup, User, UserSessionId, Workspace, WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_mapping::MappingSet;
|
use crank_mapping::MappingSet;
|
||||||
use crank_schema::Schema;
|
use crank_schema::Schema;
|
||||||
@@ -85,6 +85,16 @@ pub struct PlatformApiKeyRecord {
|
|||||||
pub api_key: PlatformApiKey,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct InvocationLogRecord {
|
pub struct InvocationLogRecord {
|
||||||
pub log: InvocationLog,
|
pub log: InvocationLog,
|
||||||
@@ -403,6 +413,25 @@ pub struct CreatePlatformApiKeyRequest<'a> {
|
|||||||
pub secret_hash: &'a str,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct SaveSampleMetadataRequest<'a> {
|
pub struct SaveSampleMetadataRequest<'a> {
|
||||||
pub sample: &'a OperationSampleMetadata,
|
pub sample: &'a OperationSampleMetadata,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use crank_core::{
|
use crank_core::{
|
||||||
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, GraphqlOperationType,
|
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, GraphqlOperationType,
|
||||||
HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole,
|
HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole,
|
||||||
OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Target,
|
OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Secret,
|
||||||
UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId,
|
SecretId, SecretVersion, Target, UsageRollup, User, UserId, UserSessionId, Workspace,
|
||||||
|
WorkspaceId,
|
||||||
};
|
};
|
||||||
use serde::{Serialize, de::DeserializeOwned};
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -18,16 +19,17 @@ use crate::{
|
|||||||
model::{
|
model::{
|
||||||
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest,
|
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest,
|
||||||
CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
|
CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
|
||||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||||
DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
|
CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord, InvocationLogRecord,
|
||||||
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||||
OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest,
|
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
|
||||||
PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest,
|
PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation,
|
||||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
||||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown,
|
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SecretRecord,
|
||||||
UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||||
WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||||
YamlImportJobStatus,
|
WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
||||||
|
YamlImportJobId, YamlImportJobStatus,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -652,6 +654,212 @@ impl PostgresRegistry {
|
|||||||
row.as_ref().map(map_platform_api_key_record).transpose()
|
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(
|
pub async fn create_platform_api_key(
|
||||||
&self,
|
&self,
|
||||||
request: CreatePlatformApiKeyRequest<'_>,
|
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> {
|
fn map_invocation_log_record(row: &PgRow) -> Result<InvocationLogRecord, RegistryError> {
|
||||||
Ok(InvocationLogRecord {
|
Ok(InvocationLogRecord {
|
||||||
log: InvocationLog {
|
log: InvocationLog {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ services:
|
|||||||
CRANK_ADMIN_BIND: ${CRANK_ADMIN_BIND:-0.0.0.0:3001}
|
CRANK_ADMIN_BIND: ${CRANK_ADMIN_BIND:-0.0.0.0:3001}
|
||||||
CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-info}
|
CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-info}
|
||||||
CRANK_SECRET_PROVIDER: ${CRANK_SECRET_PROVIDER:-env}
|
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_PUBLIC_BASE_URL: ${CRANK_PUBLIC_BASE_URL:-http://localhost:3000}
|
||||||
CRANK_SESSION_SECRET: ${CRANK_SESSION_SECRET}
|
CRANK_SESSION_SECRET: ${CRANK_SESSION_SECRET}
|
||||||
CRANK_PASSWORD_PEPPER: ${CRANK_PASSWORD_PEPPER}
|
CRANK_PASSWORD_PEPPER: ${CRANK_PASSWORD_PEPPER}
|
||||||
|
|||||||
+2
-2
@@ -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` при наличии;
|
- `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;
|
- `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.*}`.
|
- `AuthProfile.config` хранит ссылки на `secret_id`, а не placeholder-строки `${secrets.*}`.
|
||||||
|
|
||||||
### 5.6. Agents
|
### 5.6. Agents
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ var/crank/
|
|||||||
- `CRANK_MCP_REFRESH_MS`
|
- `CRANK_MCP_REFRESH_MS`
|
||||||
- `CRANK_LOG_LEVEL`
|
- `CRANK_LOG_LEVEL`
|
||||||
- `CRANK_SECRET_PROVIDER`
|
- `CRANK_SECRET_PROVIDER`
|
||||||
|
- `CRANK_MASTER_KEY`
|
||||||
- `CRANK_PUBLIC_BASE_URL`
|
- `CRANK_PUBLIC_BASE_URL`
|
||||||
- `CRANK_MCP_PUBLIC_URL`
|
- `CRANK_MCP_PUBLIC_URL`
|
||||||
|
|
||||||
Опционально:
|
Опционально:
|
||||||
|
|
||||||
- `CRANK_ADMIN_TOKEN`
|
- `CRANK_ADMIN_TOKEN`
|
||||||
- `CRANK_MASTER_KEY`
|
|
||||||
- `CRANK_DEMO_SEED`
|
- `CRANK_DEMO_SEED`
|
||||||
|
|
||||||
Стартовое значение для refresh published tools:
|
Стартовое значение для refresh published tools:
|
||||||
@@ -131,6 +131,10 @@ Demo/deployment:
|
|||||||
- `CRANK_BOOTSTRAP_ADMIN_PASSWORD`
|
- `CRANK_BOOTSTRAP_ADMIN_PASSWORD`
|
||||||
- `CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME`
|
- `CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME`
|
||||||
|
|
||||||
|
Для secret store foundation нужен:
|
||||||
|
|
||||||
|
- `CRANK_MASTER_KEY`
|
||||||
|
|
||||||
Для опционального demo-seed:
|
Для опционального demo-seed:
|
||||||
|
|
||||||
- `CRANK_DEMO_SEED=true`
|
- `CRANK_DEMO_SEED=true`
|
||||||
|
|||||||
Reference in New Issue
Block a user