From f931d9b51f041245847e0827e23dd5e3796786de Mon Sep 17 00:00:00 2001 From: github-ops Date: Sun, 21 Jun 2026 01:57:57 +0000 Subject: [PATCH] Split admin secrets service module --- apps/admin-api/src/service.rs | 248 +------------------------- apps/admin-api/src/service/secrets.rs | 248 ++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 239 deletions(-) create mode 100644 apps/admin-api/src/service/secrets.rs diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index d946a6e..503e207 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -11,20 +11,18 @@ use crank_core::{ InvocationLogId, InvocationSource, InvocationStatus, LoginOutcome, MembershipRole, NoopAuditSink, OperationId, OperationSecurityLevel, OperationStatus, OwnerOnlyPolicyEngine, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, PolicyEngine, - ProductEdition, Protocol, ResponseCachePolicy, SampleId, Samples, Secret, SecretId, SecretKind, - SecretStatus, Target, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind, - ToolQualitySchemaNode, UsagePeriod, UserId, UserSessionId, WizardState, Workspace, WorkspaceId, - WorkspaceStatus, + ProductEdition, Protocol, ResponseCachePolicy, SampleId, Samples, SecretKind, Target, + ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind, ToolQualitySchemaNode, + UsagePeriod, UserSessionId, WizardState, Workspace, WorkspaceId, WorkspaceStatus, }; use crank_mapping::{JsonPathRoot, MappingRule, MappingSet, infer_mapping_from_samples}; use crank_registry::{ AgentSummary, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, - CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, - CreateVersionRequest, CreateWorkspaceRequest, ListInvocationLogsQuery, OperationAgentRef, - OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, - PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryError, - RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, - SaveAuthProfileRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, + CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, + CreateWorkspaceRequest, ListInvocationLogsQuery, OperationAgentRef, OperationSampleMetadata, + OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, + PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, + SaveAgentBindingsRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, @@ -43,6 +41,7 @@ use uuid::Uuid; mod import_export; mod observability; +mod secrets; use crate::{ auth::{ @@ -1716,15 +1715,6 @@ impl AdminService { ResolvedAuth::from_profile(auth_profile, &secrets) } - #[instrument(skip(self))] - pub async fn list_auth_profiles( - &self, - workspace_id: &WorkspaceId, - ) -> Result, ApiError> { - self.ensure_workspace_exists(workspace_id).await?; - Ok(self.registry.list_auth_profiles(workspace_id).await?) - } - #[instrument(skip(self))] pub async fn list_workspace_upstreams( &self, @@ -1823,136 +1813,6 @@ impl AdminService { Ok(()) } - #[instrument(skip(self))] - pub async fn list_secrets(&self, workspace_id: &WorkspaceId) -> Result, ApiError> { - self.ensure_workspace_exists(workspace_id).await?; - Ok(self - .registry - .list_secrets(workspace_id) - .await? - .into_iter() - .map(|record| record.secret) - .collect()) - } - - #[instrument(skip(self))] - pub async fn get_secret( - &self, - workspace_id: &WorkspaceId, - secret_id: &SecretId, - ) -> Result { - self.ensure_workspace_exists(workspace_id).await?; - self.registry - .get_secret(workspace_id, secret_id) - .await? - .map(|record| record.secret) - .ok_or_else(|| { - ApiError::not_found_with_context( - format!("secret {} was not found", secret_id.as_str()), - json!({ "secret_id": secret_id.as_str() }), - ) - }) - } - - #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))] - pub async fn create_secret( - &self, - workspace_id: &WorkspaceId, - created_by: Option<&UserId>, - payload: SecretPayload, - ) -> Result { - self.ensure_workspace_exists(workspace_id).await?; - validate_secret_payload(&payload)?; - - let now = OffsetDateTime::now_utc(); - 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, - updated_at: now, - last_used_at: None, - }; - let ciphertext = self - .secret_crypto - .encrypt(&payload.value) - .map_err(|error| ApiError::internal(error.to_string()))?; - - self.registry - .create_secret(CreateSecretRequest { - secret: &secret, - ciphertext: &ciphertext, - key_version: self.secret_crypto.key_version(), - created_by, - }) - .await?; - info!(secret_id = %secret.id.as_str(), "secret created"); - - Ok(secret) - } - - #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] - pub async fn rotate_secret( - &self, - workspace_id: &WorkspaceId, - secret_id: &SecretId, - created_by: Option<&UserId>, - payload: RotateSecretPayload, - ) -> Result { - self.ensure_workspace_exists(workspace_id).await?; - if payload.value.is_null() { - return Err(ApiError::validation("secret value must not be null")); - } - - let now = OffsetDateTime::now_utc(); - let ciphertext = self - .secret_crypto - .encrypt(&payload.value) - .map_err(|error| ApiError::internal(error.to_string()))?; - 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?; - if let Some(profile) = self - .registry - .list_auth_profiles_referencing_secret(workspace_id, secret_id) - .await? - .into_iter() - .next() - { - return Err(RegistryError::SecretReferencedByAuthProfile { - secret_id: secret_id.as_str().to_owned(), - auth_profile_id: profile.id.as_str().to_owned(), - } - .into()); - } - 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, @@ -2421,68 +2281,6 @@ impl AdminService { }) } - #[instrument(skip(self))] - pub async fn get_auth_profile( - &self, - workspace_id: &WorkspaceId, - auth_profile_id: &AuthProfileId, - ) -> Result { - self.registry - .get_auth_profile(workspace_id, auth_profile_id) - .await? - .ok_or_else(|| { - ApiError::not_found_with_context( - format!("auth profile {} was not found", auth_profile_id.as_str()), - json!({ "auth_profile_id": auth_profile_id.as_str() }), - ) - }) - } - - #[instrument(skip(self, payload), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))] - pub async fn create_auth_profile( - &self, - workspace_id: &WorkspaceId, - payload: AuthProfilePayload, - ) -> Result { - validate_auth_profile_kind(payload.kind, &payload.config)?; - self.ensure_workspace_exists(workspace_id).await?; - self.validate_auth_profile_secret_ids(workspace_id, &payload.config) - .await?; - - let now = OffsetDateTime::now_utc(); - let profile = AuthProfile { - id: AuthProfileId::new(new_prefixed_id("auth")), - workspace_id: workspace_id.clone(), - name: payload.name, - kind: payload.kind, - config: payload.config, - created_at: now, - updated_at: now, - }; - - self.registry - .save_auth_profile(SaveAuthProfileRequest { - workspace_id, - profile: &profile, - }) - .await?; - info!(auth_profile_id = %profile.id.as_str(), "auth profile created"); - - Ok(profile) - } - - async fn validate_auth_profile_secret_ids( - &self, - workspace_id: &WorkspaceId, - config: &AuthConfig, - ) -> Result<(), ApiError> { - for secret_id in config.secret_ids() { - self.get_secret(workspace_id, secret_id).await?; - } - - Ok(()) - } - #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), sample_kind = ?sample_kind))] pub async fn save_json_sample( &self, @@ -3022,22 +2820,6 @@ fn validate_protocol_target(protocol: Protocol, target: &Target) -> Result<(), A Err(ApiError::validation("protocol and target kind must match")) } -fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), ApiError> { - let is_match = matches!( - (kind, config), - (AuthKind::Bearer, AuthConfig::Bearer(_)) - | (AuthKind::Basic, AuthConfig::Basic(_)) - | (AuthKind::ApiKeyHeader, AuthConfig::ApiKeyHeader(_)) - | (AuthKind::ApiKeyQuery, AuthConfig::ApiKeyQuery(_)) - ); - - if is_match { - return Ok(()); - } - - Err(ApiError::validation("auth kind and config must match")) -} - fn validate_upstream_payload(payload: &UpstreamPayload) -> Result<(), ApiError> { if payload.name.trim().is_empty() { return Err(ApiError::validation("upstream name is required")); @@ -3104,18 +2886,6 @@ fn validate_profile_email(value: &str) -> Result { Ok(email) } -fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> { - if payload.name.trim().is_empty() { - return Err(ApiError::validation("secret name must not be empty")); - } - - if payload.value.is_null() { - return Err(ApiError::validation("secret value must not be null")); - } - - Ok(()) -} - fn latest_sample_ref( samples: &[OperationSampleMetadata], sample_kind: SampleKind, diff --git a/apps/admin-api/src/service/secrets.rs b/apps/admin-api/src/service/secrets.rs new file mode 100644 index 0000000..009abe7 --- /dev/null +++ b/apps/admin-api/src/service/secrets.rs @@ -0,0 +1,248 @@ +use crank_core::{ + AuthConfig, AuthKind, AuthProfile, AuthProfileId, Secret, SecretId, SecretStatus, UserId, + WorkspaceId, +}; +use crank_registry::{ + CreateSecretRequest, RegistryError, RotateSecretRequest, SaveAuthProfileRequest, +}; +use serde_json::json; +use time::OffsetDateTime; +use tracing::{info, instrument}; + +use crate::{ + error::ApiError, + service::{ + AdminService, AuthProfilePayload, RotateSecretPayload, SecretPayload, new_prefixed_id, + }, +}; + +impl AdminService { + #[instrument(skip(self))] + pub async fn list_auth_profiles( + &self, + workspace_id: &WorkspaceId, + ) -> Result, ApiError> { + self.ensure_workspace_exists(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, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + Ok(self + .registry + .list_secrets(workspace_id) + .await? + .into_iter() + .map(|record| record.secret) + .collect()) + } + + #[instrument(skip(self))] + pub async fn get_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + self.registry + .get_secret(workspace_id, secret_id) + .await? + .map(|record| record.secret) + .ok_or_else(|| { + ApiError::not_found_with_context( + format!("secret {} was not found", secret_id.as_str()), + json!({ "secret_id": secret_id.as_str() }), + ) + }) + } + + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))] + pub async fn create_secret( + &self, + workspace_id: &WorkspaceId, + created_by: Option<&UserId>, + payload: SecretPayload, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + validate_secret_payload(&payload)?; + + let now = OffsetDateTime::now_utc(); + 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, + updated_at: now, + last_used_at: None, + }; + let ciphertext = self + .secret_crypto + .encrypt(&payload.value) + .map_err(|error| ApiError::internal(error.to_string()))?; + + self.registry + .create_secret(CreateSecretRequest { + secret: &secret, + ciphertext: &ciphertext, + key_version: self.secret_crypto.key_version(), + created_by, + }) + .await?; + info!(secret_id = %secret.id.as_str(), "secret created"); + + Ok(secret) + } + + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] + pub async fn rotate_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + created_by: Option<&UserId>, + payload: RotateSecretPayload, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + if payload.value.is_null() { + return Err(ApiError::validation("secret value must not be null")); + } + + let now = OffsetDateTime::now_utc(); + let ciphertext = self + .secret_crypto + .encrypt(&payload.value) + .map_err(|error| ApiError::internal(error.to_string()))?; + 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?; + if let Some(profile) = self + .registry + .list_auth_profiles_referencing_secret(workspace_id, secret_id) + .await? + .into_iter() + .next() + { + return Err(RegistryError::SecretReferencedByAuthProfile { + secret_id: secret_id.as_str().to_owned(), + auth_profile_id: profile.id.as_str().to_owned(), + } + .into()); + } + 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 get_auth_profile( + &self, + workspace_id: &WorkspaceId, + auth_profile_id: &AuthProfileId, + ) -> Result { + self.registry + .get_auth_profile(workspace_id, auth_profile_id) + .await? + .ok_or_else(|| { + ApiError::not_found_with_context( + format!("auth profile {} was not found", auth_profile_id.as_str()), + json!({ "auth_profile_id": auth_profile_id.as_str() }), + ) + }) + } + + #[instrument(skip(self, payload), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))] + pub async fn create_auth_profile( + &self, + workspace_id: &WorkspaceId, + payload: AuthProfilePayload, + ) -> Result { + validate_auth_profile_kind(payload.kind, &payload.config)?; + self.ensure_workspace_exists(workspace_id).await?; + self.validate_auth_profile_secret_ids(workspace_id, &payload.config) + .await?; + + let now = OffsetDateTime::now_utc(); + let profile = AuthProfile { + id: AuthProfileId::new(new_prefixed_id("auth")), + workspace_id: workspace_id.clone(), + name: payload.name, + kind: payload.kind, + config: payload.config, + created_at: now, + updated_at: now, + }; + + self.registry + .save_auth_profile(SaveAuthProfileRequest { + workspace_id, + profile: &profile, + }) + .await?; + info!(auth_profile_id = %profile.id.as_str(), "auth profile created"); + + Ok(profile) + } + + async fn validate_auth_profile_secret_ids( + &self, + workspace_id: &WorkspaceId, + config: &AuthConfig, + ) -> Result<(), ApiError> { + for secret_id in config.secret_ids() { + self.get_secret(workspace_id, secret_id).await?; + } + + Ok(()) + } +} + +fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), ApiError> { + let is_match = matches!( + (kind, config), + (AuthKind::Bearer, AuthConfig::Bearer(_)) + | (AuthKind::Basic, AuthConfig::Basic(_)) + | (AuthKind::ApiKeyHeader, AuthConfig::ApiKeyHeader(_)) + | (AuthKind::ApiKeyQuery, AuthConfig::ApiKeyQuery(_)) + ); + + if is_match { + return Ok(()); + } + + 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(()) +}