feat: add secret store foundation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user