diff --git a/TASKS.md b/TASKS.md index e098667..c802cd5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,19 +2,19 @@ ## Current -### `feat/soap-adapter-foundation` +### `feat/auth-profile-secret-resolution` Status: completed DoD: -- `crank-adapter-soap` supports unary SOAP request-response execution -- WSDL upload and service/port/operation inspection are available in `admin-api` -- SOAP faults are normalized into runtime errors -- workspace-scoped SOAP test runs work through the same `Operation` lifecycle +- `AuthConfig` references real `SecretId` values instead of string placeholders +- auth profile create flow validates all referenced secrets inside the workspace +- secret delete is rejected while an auth profile still references the secret +- tests cover missing-secret validation and reference-protected secret deletion ## Next -- `feat/auth-profile-secret-resolution` +- `feat/runtime-upstream-auth` ## Backlog diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 1bc8da9..c5f0d64 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -1520,6 +1520,18 @@ mod tests { let upstream_base_url = spawn_upstream_server().await; let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; + let secret = client + .post(format!("{base_url}/secrets")) + .json(&json!({ + "name": "crm-api-token", + "kind": SecretKind::Token, + "value": { "token": "super-secret-token" } + })) + .send() + .await + .unwrap(); + let secret = assert_success_json(secret).await; + let secret_id = secret["id"].as_str().unwrap(); let auth_profile = client .post(format!("{base_url}/auth-profiles")) @@ -1529,7 +1541,7 @@ mod tests { "config": { "api_key_header": { "header_name": "X-Api-Key", - "secret_ref": "secret://crm/api-key" + "secret_id": secret_id } } })) @@ -1571,6 +1583,10 @@ mod tests { .unwrap(); assert_eq!(auth_profile["kind"], "api_key_header"); + assert_eq!( + auth_profile["config"]["api_key_header"]["secret_id"], + secret_id + ); assert_eq!(imported["operation_id"], operation_id); assert_eq!(imported["version"], 2); assert_eq!(imported["import_mode"], "upsert"); @@ -1649,6 +1665,95 @@ mod tests { assert_eq!(missing["error"]["code"], "not_found"); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_auth_profile_with_missing_secret() { + let registry = test_registry().await; + let storage_root = test_storage_root("missing_secret_auth"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let response = client + .post(format!("{base_url}/auth-profiles")) + .json(&json!({ + "name": "crm-header", + "kind": "api_key_header", + "config": { + "api_key_header": { + "header_name": "X-Api-Key", + "secret_id": "secret_missing" + } + } + })) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::NOT_FOUND); + assert_eq!(body["error"]["code"], "not_found"); + assert_eq!( + body["error"]["message"], + "secret secret_missing was not found" + ); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_deleting_secret_referenced_by_auth_profile() { + let registry = test_registry().await; + let storage_root = test_storage_root("secret_references"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let secret = client + .post(format!("{base_url}/secrets")) + .json(&json!({ + "name": "crm-api-token", + "kind": SecretKind::Token, + "value": { "token": "super-secret-token" } + })) + .send() + .await + .unwrap(); + let secret = assert_success_json(secret).await; + let secret_id = secret["id"].as_str().unwrap(); + + let auth_profile = client + .post(format!("{base_url}/auth-profiles")) + .json(&json!({ + "name": "crm-header", + "kind": "api_key_header", + "config": { + "api_key_header": { + "header_name": "X-Api-Key", + "secret_id": secret_id + } + } + })) + .send() + .await + .unwrap(); + let auth_profile = assert_success_json(auth_profile).await; + let auth_profile_id = auth_profile["id"].as_str().unwrap(); + + let response = client + .delete(format!("{base_url}/secrets/{secret_id}")) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "conflict"); + assert_eq!( + body["error"]["message"], + format!("secret {secret_id} is referenced by auth profile {auth_profile_id}") + ); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn roundtrips_graphql_operation_through_yaml_upsert() { diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 9a5e5b6..2ffe509 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -183,6 +183,12 @@ impl From for ApiError { RegistryError::SecretNameAlreadyExists { workspace_id, name } => Self::conflict( format!("secret with name {name} already exists in workspace {workspace_id}"), ), + RegistryError::SecretReferencedByAuthProfile { + secret_id, + auth_profile_id, + } => Self::conflict(format!( + "secret {secret_id} is referenced by auth profile {auth_profile_id}" + )), RegistryError::InvalidStreamSessionTransition { .. } | RegistryError::InvalidAsyncJobTransition { .. } => Self::conflict(value.to_string()), RegistryError::UserEmailAlreadyExists { email } => { diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 517a320..0a2d8cc 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -25,11 +25,11 @@ use crank_registry::{ CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, - PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, RotateSecretRequest, - SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, - SaveSampleMetadataRequest, StreamSessionFilter, UpdateWorkspaceRequest, UsageAgentBreakdown, - UsageBucket, UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, - WorkspaceMembershipRecord, WorkspaceRecord, + PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, + RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, + SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, StreamSessionFilter, + UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, + UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, }; use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation}; use crank_schema::Schema; @@ -2085,6 +2085,19 @@ impl AdminService { 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(()) @@ -2774,6 +2787,8 @@ impl AdminService { ) -> 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 = now_string()?; let profile = AuthProfile { @@ -2797,6 +2812,18 @@ impl AdminService { 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), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))] pub async fn export_operation( &self, diff --git a/crates/crank-core/src/auth.rs b/crates/crank-core/src/auth.rs index 0691757..58f1d26 100644 --- a/crates/crank-core/src/auth.rs +++ b/crates/crank-core/src/auth.rs @@ -1,45 +1,32 @@ use serde::{Deserialize, Serialize}; use crate::{ - ids::{AuthProfileId, WorkspaceId}, + ids::{AuthProfileId, SecretId, WorkspaceId}, protocol::AuthKind, }; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SecretRef(pub String); - -impl SecretRef { - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BearerAuthConfig { pub header_name: String, - pub secret_ref: SecretRef, + pub secret_id: SecretId, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BasicAuthConfig { - pub username_secret_ref: SecretRef, - pub password_secret_ref: SecretRef, + pub username_secret_id: SecretId, + pub password_secret_id: SecretId, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ApiKeyHeaderAuthConfig { pub header_name: String, - pub secret_ref: SecretRef, + pub secret_id: SecretId, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ApiKeyQueryAuthConfig { pub param_name: String, - pub secret_ref: SecretRef, + pub secret_id: SecretId, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -51,6 +38,17 @@ pub enum AuthConfig { ApiKeyQuery(ApiKeyQueryAuthConfig), } +impl AuthConfig { + pub fn secret_ids(&self) -> Vec<&SecretId> { + match self { + Self::Bearer(config) => vec![&config.secret_id], + Self::Basic(config) => vec![&config.username_secret_id, &config.password_secret_id], + Self::ApiKeyHeader(config) => vec![&config.secret_id], + Self::ApiKeyQuery(config) => vec![&config.secret_id], + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthProfile { pub id: AuthProfileId, diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index 22bf5fb..a62ef21 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -18,7 +18,7 @@ pub use access::{ pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion}; pub use auth::{ ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig, - BearerAuthConfig, SecretRef, + BearerAuthConfig, }; pub use ids::{ AgentId, AsyncJobId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId, diff --git a/crates/crank-core/src/operation.rs b/crates/crank-core/src/operation.rs index f1412dd..ef01137 100644 --- a/crates/crank-core/src/operation.rs +++ b/crates/crank-core/src/operation.rs @@ -266,7 +266,7 @@ mod tests { use serde_json::json; use crate::{ - auth::{AuthConfig, AuthProfile, BearerAuthConfig, SecretRef}, + auth::{AuthConfig, AuthProfile, BearerAuthConfig}, ids::{AuthProfileId, OperationId, SampleId}, operation::{ ConfigExport, ExecutionConfig, GraphqlTarget, Operation, OperationStatus, @@ -430,7 +430,7 @@ mod tests { } #[test] - fn auth_profile_serializes_secret_refs_without_secret_values() { + fn auth_profile_serializes_secret_ids_without_secret_values() { let profile = AuthProfile { id: AuthProfileId::new("auth_01"), workspace_id: crate::ids::WorkspaceId::new("ws_01"), @@ -438,7 +438,7 @@ mod tests { kind: AuthKind::Bearer, config: AuthConfig::Bearer(BearerAuthConfig { header_name: "Authorization".to_owned(), - secret_ref: SecretRef::new("secret://auth/crm-prod-token"), + secret_id: crate::ids::SecretId::new("secret_crm_prod_token"), }), created_at: "2026-03-25T08:00:00Z".to_owned(), updated_at: "2026-03-25T08:00:00Z".to_owned(), @@ -448,8 +448,8 @@ mod tests { assert_eq!(value["kind"], "bearer"); assert_eq!( - value["config"]["bearer"]["secret_ref"], - "secret://auth/crm-prod-token" + value["config"]["bearer"]["secret_id"], + "secret_crm_prod_token" ); } diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index 2db2998..3c9da77 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -43,6 +43,11 @@ pub enum RegistryError { }, #[error("secret with name {name} already exists in workspace {workspace_id}")] SecretNameAlreadyExists { workspace_id: String, name: String }, + #[error("secret {secret_id} is referenced by auth profile {auth_profile_id}")] + SecretReferencedByAuthProfile { + secret_id: String, + auth_profile_id: String, + }, #[error("invocation log {log_id} was not found")] InvocationLogNotFound { log_id: String }, #[error("agent {agent_id} was not found")] diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index 3b79368..cade8f0 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -3258,6 +3258,25 @@ impl PostgresRegistry { rows.iter().map(map_auth_profile).collect() } + pub async fn list_auth_profiles_referencing_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + ) -> Result, RegistryError> { + let profiles = self.list_auth_profiles(workspace_id).await?; + + Ok(profiles + .into_iter() + .filter(|profile| { + profile + .config + .secret_ids() + .into_iter() + .any(|candidate| candidate == secret_id) + }) + .collect()) + } + pub async fn save_sample_metadata( &self, request: SaveSampleMetadataRequest<'_>, @@ -4331,7 +4350,7 @@ mod tests { use crank_core::{ ApiKeyHeaderAuthConfig, AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft, GeneratedDraftStatus, HttpMethod, JobStatus, - OperationId, OperationStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretRef, + OperationId, OperationStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretId, StreamSession, StreamSessionId, StreamStatus, Target, ToolDescription, ToolExample, WorkspaceId, }; @@ -4471,7 +4490,7 @@ mod tests { kind: AuthKind::ApiKeyHeader, config: AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig { header_name: "X-Api-Key".to_owned(), - secret_ref: SecretRef::new("vault://crank/api-key"), + secret_id: SecretId::new("secret_crank_api_key"), }), created_at: "2026-03-25T12:00:00Z".to_owned(), updated_at: "2026-03-25T12:00:00Z".to_owned(), @@ -4539,6 +4558,43 @@ mod tests { database.cleanup().await; } + #[tokio::test] + async fn lists_auth_profiles_referencing_secret() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let primary_secret_id = SecretId::new("secret_primary"); + let secondary_secret_id = SecretId::new("secret_secondary"); + let profile = AuthProfile { + id: "auth_crank".into(), + workspace_id: test_workspace_id(), + name: "Crank basic auth".to_owned(), + kind: AuthKind::Basic, + config: AuthConfig::Basic(crank_core::BasicAuthConfig { + username_secret_id: primary_secret_id.clone(), + password_secret_id: secondary_secret_id.clone(), + }), + created_at: "2026-03-25T12:00:00Z".to_owned(), + updated_at: "2026-03-25T12:00:00Z".to_owned(), + }; + + registry + .save_auth_profile(SaveAuthProfileRequest { + workspace_id: &test_workspace_id(), + profile: &profile, + }) + .await + .unwrap(); + + let profiles = registry + .list_auth_profiles_referencing_secret(&test_workspace_id(), &primary_secret_id) + .await + .unwrap(); + + assert_eq!(profiles, vec![profile]); + + database.cleanup().await; + } + #[tokio::test] async fn stores_and_finishes_yaml_import_jobs() { let database = TestDatabase::new().await; diff --git a/docs/admin-api.md b/docs/admin-api.md index f121f57..4e86c14 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -147,7 +147,7 @@ - `POST /secrets` принимает metadata и plaintext value, но create-response возвращает только metadata; - `GET /secrets` и `GET /secrets/{secret_id}` возвращают только metadata, `kind`, `status`, `current_version`, `created_at`, `updated_at`, `last_used_at` при наличии; - `POST /secrets/{secret_id}/rotate` создает новую secret version; -- `DELETE /secrets/{secret_id}` в secret foundation удаляет secret без reference checks; валидация ссылок добавляется в `feat/auth-profile-secret-resolution`; +- `DELETE /secrets/{secret_id}` отклоняется, если secret все еще используется `AuthProfile`; - `AuthProfile.config` хранит ссылки на `secret_id`, а не placeholder-строки `${secrets.*}`. ### 5.6. Agents