feat: resolve auth profiles through secret ids

This commit is contained in:
a.tolmachev
2026-04-07 00:43:35 +03:00
parent a6388e4353
commit 191e749b14
10 changed files with 237 additions and 40 deletions
+6 -6
View File
@@ -2,19 +2,19 @@
## Current ## Current
### `feat/soap-adapter-foundation` ### `feat/auth-profile-secret-resolution`
Status: completed Status: completed
DoD: DoD:
- `crank-adapter-soap` supports unary SOAP request-response execution - `AuthConfig` references real `SecretId` values instead of string placeholders
- WSDL upload and service/port/operation inspection are available in `admin-api` - auth profile create flow validates all referenced secrets inside the workspace
- SOAP faults are normalized into runtime errors - secret delete is rejected while an auth profile still references the secret
- workspace-scoped SOAP test runs work through the same `Operation` lifecycle - tests cover missing-secret validation and reference-protected secret deletion
## Next ## Next
- `feat/auth-profile-secret-resolution` - `feat/runtime-upstream-auth`
## Backlog ## Backlog
+106 -1
View File
@@ -1520,6 +1520,18 @@ mod tests {
let upstream_base_url = spawn_upstream_server().await; let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).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 let auth_profile = client
.post(format!("{base_url}/auth-profiles")) .post(format!("{base_url}/auth-profiles"))
@@ -1529,7 +1541,7 @@ mod tests {
"config": { "config": {
"api_key_header": { "api_key_header": {
"header_name": "X-Api-Key", "header_name": "X-Api-Key",
"secret_ref": "secret://crm/api-key" "secret_id": secret_id
} }
} }
})) }))
@@ -1571,6 +1583,10 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(auth_profile["kind"], "api_key_header"); 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["operation_id"], operation_id);
assert_eq!(imported["version"], 2); assert_eq!(imported["version"], 2);
assert_eq!(imported["import_mode"], "upsert"); assert_eq!(imported["import_mode"], "upsert");
@@ -1649,6 +1665,95 @@ mod tests {
assert_eq!(missing["error"]["code"], "not_found"); 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::<Value>().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::<Value>().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")] #[tokio::test(flavor = "multi_thread")]
#[serial] #[serial]
async fn roundtrips_graphql_operation_through_yaml_upsert() { async fn roundtrips_graphql_operation_through_yaml_upsert() {
+6
View File
@@ -183,6 +183,12 @@ impl From<RegistryError> for ApiError {
RegistryError::SecretNameAlreadyExists { workspace_id, name } => Self::conflict( RegistryError::SecretNameAlreadyExists { workspace_id, name } => Self::conflict(
format!("secret with name {name} already exists in workspace {workspace_id}"), 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::InvalidStreamSessionTransition { .. }
| RegistryError::InvalidAsyncJobTransition { .. } => Self::conflict(value.to_string()), | RegistryError::InvalidAsyncJobTransition { .. } => Self::conflict(value.to_string()),
RegistryError::UserEmailAlreadyExists { email } => { RegistryError::UserEmailAlreadyExists { email } => {
+32 -5
View File
@@ -25,11 +25,11 @@ use crank_registry::{
CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord,
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord,
PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, RotateSecretRequest, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation,
SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest,
SaveSampleMetadataRequest, StreamSessionFilter, UpdateWorkspaceRequest, UsageAgentBreakdown, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, StreamSessionFilter,
UsageBucket, UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
WorkspaceMembershipRecord, WorkspaceRecord, 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;
@@ -2085,6 +2085,19 @@ impl AdminService {
secret_id: &SecretId, secret_id: &SecretId,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
self.ensure_workspace_exists(workspace_id).await?; 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?; self.registry.delete_secret(workspace_id, secret_id).await?;
info!(secret_id = %secret_id.as_str(), "secret deleted"); info!(secret_id = %secret_id.as_str(), "secret deleted");
Ok(()) Ok(())
@@ -2774,6 +2787,8 @@ impl AdminService {
) -> Result<AuthProfile, ApiError> { ) -> Result<AuthProfile, ApiError> {
validate_auth_profile_kind(payload.kind, &payload.config)?; validate_auth_profile_kind(payload.kind, &payload.config)?;
self.ensure_workspace_exists(workspace_id).await?; self.ensure_workspace_exists(workspace_id).await?;
self.validate_auth_profile_secret_ids(workspace_id, &payload.config)
.await?;
let now = now_string()?; let now = now_string()?;
let profile = AuthProfile { let profile = AuthProfile {
@@ -2797,6 +2812,18 @@ impl AdminService {
Ok(profile) 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))] #[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))]
pub async fn export_operation( pub async fn export_operation(
&self, &self,
+17 -19
View File
@@ -1,45 +1,32 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
ids::{AuthProfileId, WorkspaceId}, ids::{AuthProfileId, SecretId, WorkspaceId},
protocol::AuthKind, protocol::AuthKind,
}; };
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretRef(pub String);
impl SecretRef {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BearerAuthConfig { pub struct BearerAuthConfig {
pub header_name: String, pub header_name: String,
pub secret_ref: SecretRef, pub secret_id: SecretId,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BasicAuthConfig { pub struct BasicAuthConfig {
pub username_secret_ref: SecretRef, pub username_secret_id: SecretId,
pub password_secret_ref: SecretRef, pub password_secret_id: SecretId,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiKeyHeaderAuthConfig { pub struct ApiKeyHeaderAuthConfig {
pub header_name: String, pub header_name: String,
pub secret_ref: SecretRef, pub secret_id: SecretId,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiKeyQueryAuthConfig { pub struct ApiKeyQueryAuthConfig {
pub param_name: String, pub param_name: String,
pub secret_ref: SecretRef, pub secret_id: SecretId,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -51,6 +38,17 @@ pub enum AuthConfig {
ApiKeyQuery(ApiKeyQueryAuthConfig), 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthProfile { pub struct AuthProfile {
pub id: AuthProfileId, pub id: AuthProfileId,
+1 -1
View File
@@ -18,7 +18,7 @@ pub use access::{
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion}; pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
pub use auth::{ pub use auth::{
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig, ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
BearerAuthConfig, SecretRef, BearerAuthConfig,
}; };
pub use ids::{ pub use ids::{
AgentId, AsyncJobId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId, AgentId, AsyncJobId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId,
+5 -5
View File
@@ -266,7 +266,7 @@ mod tests {
use serde_json::json; use serde_json::json;
use crate::{ use crate::{
auth::{AuthConfig, AuthProfile, BearerAuthConfig, SecretRef}, auth::{AuthConfig, AuthProfile, BearerAuthConfig},
ids::{AuthProfileId, OperationId, SampleId}, ids::{AuthProfileId, OperationId, SampleId},
operation::{ operation::{
ConfigExport, ExecutionConfig, GraphqlTarget, Operation, OperationStatus, ConfigExport, ExecutionConfig, GraphqlTarget, Operation, OperationStatus,
@@ -430,7 +430,7 @@ mod tests {
} }
#[test] #[test]
fn auth_profile_serializes_secret_refs_without_secret_values() { fn auth_profile_serializes_secret_ids_without_secret_values() {
let profile = AuthProfile { let profile = AuthProfile {
id: AuthProfileId::new("auth_01"), id: AuthProfileId::new("auth_01"),
workspace_id: crate::ids::WorkspaceId::new("ws_01"), workspace_id: crate::ids::WorkspaceId::new("ws_01"),
@@ -438,7 +438,7 @@ mod tests {
kind: AuthKind::Bearer, kind: AuthKind::Bearer,
config: AuthConfig::Bearer(BearerAuthConfig { config: AuthConfig::Bearer(BearerAuthConfig {
header_name: "Authorization".to_owned(), 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(), created_at: "2026-03-25T08:00:00Z".to_owned(),
updated_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["kind"], "bearer");
assert_eq!( assert_eq!(
value["config"]["bearer"]["secret_ref"], value["config"]["bearer"]["secret_id"],
"secret://auth/crm-prod-token" "secret_crm_prod_token"
); );
} }
+5
View File
@@ -43,6 +43,11 @@ pub enum RegistryError {
}, },
#[error("secret with name {name} already exists in workspace {workspace_id}")] #[error("secret with name {name} already exists in workspace {workspace_id}")]
SecretNameAlreadyExists { workspace_id: String, name: String }, 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")] #[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")]
+58 -2
View File
@@ -3258,6 +3258,25 @@ impl PostgresRegistry {
rows.iter().map(map_auth_profile).collect() rows.iter().map(map_auth_profile).collect()
} }
pub async fn list_auth_profiles_referencing_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<Vec<AuthProfile>, 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( pub async fn save_sample_metadata(
&self, &self,
request: SaveSampleMetadataRequest<'_>, request: SaveSampleMetadataRequest<'_>,
@@ -4331,7 +4350,7 @@ mod tests {
use crank_core::{ use crank_core::{
ApiKeyHeaderAuthConfig, AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, ConfigExport, ApiKeyHeaderAuthConfig, AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, ConfigExport,
ExecutionConfig, ExportMode, GeneratedDraft, GeneratedDraftStatus, HttpMethod, JobStatus, 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, StreamSession, StreamSessionId, StreamStatus, Target, ToolDescription, ToolExample,
WorkspaceId, WorkspaceId,
}; };
@@ -4471,7 +4490,7 @@ mod tests {
kind: AuthKind::ApiKeyHeader, kind: AuthKind::ApiKeyHeader,
config: AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig { config: AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig {
header_name: "X-Api-Key".to_owned(), 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(), created_at: "2026-03-25T12:00:00Z".to_owned(),
updated_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; 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] #[tokio::test]
async fn stores_and_finishes_yaml_import_jobs() { async fn stores_and_finishes_yaml_import_jobs() {
let database = TestDatabase::new().await; let database = TestDatabase::new().await;
+1 -1
View File
@@ -147,7 +147,7 @@
- `POST /secrets` принимает metadata и plaintext value, но create-response возвращает только metadata; - `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 foundation удаляет secret без reference checks; валидация ссылок добавляется в `feat/auth-profile-secret-resolution`; - `DELETE /secrets/{secret_id}` отклоняется, если secret все еще используется `AuthProfile`;
- `AuthProfile.config` хранит ссылки на `secret_id`, а не placeholder-строки `${secrets.*}`. - `AuthProfile.config` хранит ссылки на `secret_id`, а не placeholder-строки `${secrets.*}`.
### 5.6. Agents ### 5.6. Agents