feat: resolve auth profiles through secret ids
This commit is contained in:
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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 } => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user