mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-29 08:27:06 +00:00
refactor(plugins): harden target-plugin system and admin plugin contract (#4217)
This commit is contained in:
@@ -33,10 +33,9 @@ use rustfs_extension_schema::{
|
||||
};
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
use rustfs_targets::{
|
||||
OpsDiagnosticsRegistry, OpsProfilerRegistry, TargetPluginExternalFlowGate, TargetPluginExternalFlowGateStatus,
|
||||
builtin_extension_schemas, builtin_ops_diagnostics_contract, builtin_ops_diagnostics_extension_schema,
|
||||
builtin_ops_profiler_contract, builtin_ops_profiler_extension_schema, catalog::example_external_webhook_plugin,
|
||||
target_marketplace_extension_schema,
|
||||
TargetPluginExternalFlowGate, TargetPluginExternalFlowGateStatus, builtin_extension_schemas,
|
||||
builtin_ops_diagnostics_contract, builtin_ops_diagnostics_extension_schema, builtin_ops_profiler_contract,
|
||||
builtin_ops_profiler_extension_schema,
|
||||
};
|
||||
use s3s::header::CONTENT_TYPE;
|
||||
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
|
||||
@@ -105,14 +104,14 @@ pub(crate) struct ExtensionInstanceEntry {
|
||||
pub config: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub operational_state: Option<PluginOperationalStateContract>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub diagnostic_codes: Vec<PluginInstanceDiagnosticCode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ExtensionInstancesResponse {
|
||||
pub instances: Vec<ExtensionInstanceEntry>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub diagnostic_counts: Vec<PluginInstanceDiagnosticCount>,
|
||||
pub truncated: bool,
|
||||
pub next_marker: Option<String>,
|
||||
@@ -121,8 +120,6 @@ pub(crate) struct ExtensionInstancesResponse {
|
||||
async fn build_extension_catalog_response()
|
||||
-> Result<ExtensionCatalogResponse, crate::admin::storage_api::cluster::CapabilitySnapshotError> {
|
||||
let mut extensions = builtin_extension_schemas();
|
||||
let example = example_external_webhook_plugin();
|
||||
extensions.push(target_marketplace_extension_schema(&example.manifest));
|
||||
extensions.sort_by(|a, b| a.extension_id.cmp(&b.extension_id));
|
||||
let runtime_capabilities = system::build_runtime_capabilities_response().await?;
|
||||
let cluster_snapshot = cluster_snapshot::build_cluster_snapshot_discovery_response().await;
|
||||
@@ -140,21 +137,8 @@ fn build_extension_runtime_capabilities_response(
|
||||
) -> ExtensionRuntimeCapabilitiesResponse {
|
||||
let ops_diagnostics_schema = builtin_ops_diagnostics_extension_schema();
|
||||
let ops_diagnostics_contract = builtin_ops_diagnostics_contract();
|
||||
let mut ops_diagnostics_registry = OpsDiagnosticsRegistry::new();
|
||||
debug_assert!(
|
||||
ops_diagnostics_registry
|
||||
.register_schema(&ops_diagnostics_schema, &ops_diagnostics_contract)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let ops_profiler_schema = builtin_ops_profiler_extension_schema();
|
||||
let ops_profiler_contract = builtin_ops_profiler_contract();
|
||||
let mut ops_profiler_registry = OpsProfilerRegistry::new();
|
||||
debug_assert!(
|
||||
ops_profiler_registry
|
||||
.register_schema(&ops_profiler_schema, &ops_profiler_contract)
|
||||
.is_ok()
|
||||
);
|
||||
let runtime_capability_path = Some(format!("{}{}", ADMIN_PREFIX, system::RUNTIME_CAPABILITIES_ROUTE_SUFFIX));
|
||||
|
||||
ExtensionRuntimeCapabilitiesResponse {
|
||||
@@ -336,6 +320,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_ops_schemas_register_cleanly_in_runtime_registries() {
|
||||
let mut diagnostics_registry = rustfs_targets::OpsDiagnosticsRegistry::new();
|
||||
diagnostics_registry
|
||||
.register_schema(
|
||||
&rustfs_targets::builtin_ops_diagnostics_extension_schema(),
|
||||
&rustfs_targets::builtin_ops_diagnostics_contract(),
|
||||
)
|
||||
.expect("builtin ops diagnostics schema should register");
|
||||
|
||||
let mut profiler_registry = rustfs_targets::OpsProfilerRegistry::new();
|
||||
profiler_registry
|
||||
.register_schema(
|
||||
&rustfs_targets::builtin_ops_profiler_extension_schema(),
|
||||
&rustfs_targets::builtin_ops_profiler_contract(),
|
||||
)
|
||||
.expect("builtin ops profiler schema should register");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extension_catalog_never_exposes_example_or_external_fixtures() {
|
||||
let response = build_extension_catalog_response()
|
||||
.await
|
||||
.expect("extension catalog response should build");
|
||||
|
||||
assert!(
|
||||
!response
|
||||
.extensions
|
||||
.iter()
|
||||
.any(|schema| schema.extension_id.starts_with("external:")),
|
||||
"example external plugin fixtures must not leak into the production extension catalog"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extension_catalog_exposes_valid_extension_schemas() {
|
||||
let response = build_extension_catalog_response()
|
||||
|
||||
@@ -30,10 +30,7 @@ use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
use rustfs_targets::catalog::{
|
||||
builtin::builtin_audit_target_admin_descriptors, builtin::builtin_notify_target_admin_descriptors,
|
||||
};
|
||||
use rustfs_targets::{
|
||||
BuiltinTargetAdminDescriptor, builtin_target_marketplace_manifest, builtin_target_plugin_installation,
|
||||
catalog::example_external_webhook_plugin,
|
||||
};
|
||||
use rustfs_targets::{BuiltinTargetAdminDescriptor, builtin_target_marketplace_manifest, builtin_target_plugin_installation};
|
||||
use s3s::header::CONTENT_TYPE;
|
||||
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
|
||||
use serde::Serialize;
|
||||
@@ -69,7 +66,6 @@ fn build_catalog_response() -> PluginCatalogResponse {
|
||||
}
|
||||
|
||||
let mut plugins = plugins.into_values().collect::<Vec<_>>();
|
||||
plugins.push(example_external_webhook_plugin_entry());
|
||||
plugins.sort_by(|a, b| a.target_type.cmp(&b.target_type));
|
||||
for plugin in &mut plugins {
|
||||
plugin.supported_domains.sort();
|
||||
@@ -86,32 +82,6 @@ fn build_catalog_response() -> PluginCatalogResponse {
|
||||
}
|
||||
}
|
||||
|
||||
fn example_external_webhook_plugin_entry() -> PluginCatalogEntry {
|
||||
let example = example_external_webhook_plugin();
|
||||
let manifest = example.manifest;
|
||||
|
||||
PluginCatalogEntry {
|
||||
plugin_id: manifest.plugin_id.to_string(),
|
||||
target_type: manifest.target_type.to_string(),
|
||||
display_name: manifest.display_name.to_string(),
|
||||
provider: manifest.provider.to_string(),
|
||||
version: manifest.version.to_string(),
|
||||
packaging: PluginContractPackaging::from(manifest.packaging),
|
||||
entrypoint_kind: PluginContractEntrypointKind::from(manifest.entrypoint_kind),
|
||||
api_compatibility_version: manifest.api_compatibility_version.to_string(),
|
||||
runtime_contract: PluginRuntimeContract::from(manifest.runtime_contract),
|
||||
distribution: manifest.distribution.map(PluginDistributionContract::from),
|
||||
supported_domains: manifest.supported_domains.iter().copied().map(Into::into).collect(),
|
||||
secret_fields: manifest.secret_fields.iter().map(|field| (*field).to_string()).collect(),
|
||||
domain_configs: vec![PluginCatalogDomainEntry {
|
||||
domain: PluginContractDomain::Notify,
|
||||
subsystem: "notify_webhook".to_string(),
|
||||
valid_fields: example.valid_fields,
|
||||
}],
|
||||
installation: Some(example.installation.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_catalog_descriptor(plugins: &mut HashMap<&'static str, PluginCatalogEntry>, descriptor: &BuiltinTargetAdminDescriptor) {
|
||||
let manifest = descriptor.manifest();
|
||||
let marketplace = builtin_target_marketplace_manifest(manifest.target_type);
|
||||
@@ -251,6 +221,26 @@ mod tests {
|
||||
assert!(kafka.domain_configs.iter().any(|domain| domain.subsystem == "notify_kafka"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_catalog_never_exposes_example_or_external_fixtures() {
|
||||
let response = build_catalog_response();
|
||||
|
||||
assert!(
|
||||
response
|
||||
.plugins
|
||||
.iter()
|
||||
.all(|plugin| plugin.packaging == PluginContractPackaging::Builtin),
|
||||
"production plugin catalog must only expose builtin plugins until a real marketplace source exists"
|
||||
);
|
||||
assert!(
|
||||
!response
|
||||
.plugins
|
||||
.iter()
|
||||
.any(|plugin| plugin.plugin_id.starts_with("external:")),
|
||||
"example external plugin fixtures must not leak into the production catalog"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_catalog_exposes_secret_fields_only_as_metadata() {
|
||||
let response = build_catalog_response();
|
||||
|
||||
@@ -43,7 +43,6 @@ use rustfs_config::server_config::{Config, KVS};
|
||||
use rustfs_config::{AUDIT_DEFAULT_DIR, EVENT_DEFAULT_DIR, MAX_ADMIN_REQUEST_BODY_SIZE};
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
use rustfs_targets::catalog::builtin::{builtin_audit_target_admin_descriptors, builtin_notify_target_admin_descriptors};
|
||||
use rustfs_targets::manifest::builtin_target_manifest;
|
||||
use rustfs_targets::{builtin_target_plugin_operational_state, runtime_state_from_status_label};
|
||||
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
@@ -100,27 +99,18 @@ fn audit_target_specs() -> &'static [AdminTargetSpec] {
|
||||
|
||||
const REDACTED_SECRET_VALUE: &str = "***redacted***";
|
||||
|
||||
fn builtin_secret_fields_for_service(plugin_id: &str, service: &str) -> &'static [&'static str] {
|
||||
if !plugin_id.starts_with("builtin:") {
|
||||
return &[];
|
||||
}
|
||||
|
||||
match service.to_ascii_lowercase().as_str() {
|
||||
"webhook" => builtin_target_manifest("webhook").secret_fields,
|
||||
"mqtt" => builtin_target_manifest("mqtt").secret_fields,
|
||||
"kafka" => builtin_target_manifest("kafka").secret_fields,
|
||||
"amqp" => builtin_target_manifest("amqp").secret_fields,
|
||||
"nats" => builtin_target_manifest("nats").secret_fields,
|
||||
"pulsar" => builtin_target_manifest("pulsar").secret_fields,
|
||||
"mysql" => builtin_target_manifest("mysql").secret_fields,
|
||||
"redis" => builtin_target_manifest("redis").secret_fields,
|
||||
"postgres" => builtin_target_manifest("postgres").secret_fields,
|
||||
_ => &[],
|
||||
}
|
||||
/// Secret fields for an instance, resolved from the plugin manifest carried by
|
||||
/// the domain's admin target specs (single source of truth for redaction).
|
||||
fn secret_fields_for_instance(domain: PluginContractDomain, subsystem: &str) -> &'static [&'static str] {
|
||||
plugin_instance_domain_context(domain)
|
||||
.specs
|
||||
.iter()
|
||||
.find(|spec| spec.subsystem == subsystem)
|
||||
.map(|spec| spec.secret_fields)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn map_instance_config(config: KVS, plugin_id: &str, service: &str) -> HashMap<String, String> {
|
||||
let secret_fields = builtin_secret_fields_for_service(plugin_id, service);
|
||||
fn map_instance_config(config: KVS, secret_fields: &[&str]) -> HashMap<String, String> {
|
||||
config
|
||||
.0
|
||||
.into_iter()
|
||||
@@ -136,6 +126,46 @@ fn map_instance_config(config: KVS, plugin_id: &str, service: &str) -> HashMap<S
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Restores stored secret values for keys the client sent back as the
|
||||
/// redacted placeholder. GET responses redact secrets, so a read-modify-write
|
||||
/// round trip must not persist the placeholder literal as the new secret.
|
||||
fn restore_redacted_secret_values(
|
||||
key_values: &mut [KeyValue],
|
||||
secret_fields: &[&str],
|
||||
stored_config: Option<&KVS>,
|
||||
) -> S3Result<()> {
|
||||
for kv in key_values {
|
||||
if kv.value != REDACTED_SECRET_VALUE {
|
||||
continue;
|
||||
}
|
||||
if !secret_fields.iter().any(|field| field.eq_ignore_ascii_case(&kv.key)) {
|
||||
continue;
|
||||
}
|
||||
let stored = stored_config
|
||||
.and_then(|kvs| kvs.lookup(&kv.key))
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
s3_error!(
|
||||
InvalidArgument,
|
||||
"config key '{}' uses the redacted placeholder but there is no stored secret to preserve",
|
||||
kv.key
|
||||
)
|
||||
})?;
|
||||
kv.value = stored;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case-insensitive lookup of an instance's persisted config section.
|
||||
fn stored_instance_config<'a>(config: &'a Config, subsystem: &str, target_name: &str) -> Option<&'a KVS> {
|
||||
config.0.get(subsystem).and_then(|section| {
|
||||
section
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case(target_name))
|
||||
.map(|(_, kvs)| kvs)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct PluginInstanceDomainContext {
|
||||
domain: PluginContractDomain,
|
||||
@@ -163,17 +193,17 @@ fn plugin_instance_domain_context(domain: PluginContractDomain) -> PluginInstanc
|
||||
|
||||
fn map_instance(instance: TargetInstanceReadModel) -> PluginInstanceEntry {
|
||||
let runtime_state = runtime_state_from_status_label(&instance.status);
|
||||
let plugin_id = instance.plugin_id;
|
||||
let service = instance.service;
|
||||
let config = map_instance_config(instance.config, &plugin_id, &service);
|
||||
let domain = PluginContractDomain::from(instance.domain);
|
||||
let secret_fields = secret_fields_for_instance(domain, &instance.subsystem);
|
||||
let config = map_instance_config(instance.config, secret_fields);
|
||||
|
||||
PluginInstanceEntry {
|
||||
id: instance.canonical_id,
|
||||
plugin_id,
|
||||
domain: PluginContractDomain::from(instance.domain),
|
||||
plugin_id: instance.plugin_id,
|
||||
domain,
|
||||
subsystem: instance.subsystem,
|
||||
account_id: instance.account_id,
|
||||
service,
|
||||
service: instance.service,
|
||||
status: instance.status,
|
||||
source: map_instance_source(instance.source),
|
||||
enabled: instance.enabled,
|
||||
@@ -236,10 +266,12 @@ fn collect_instance_diagnostics(
|
||||
diagnostics
|
||||
}
|
||||
|
||||
async fn plugin_instance_detail(instance: TargetInstanceReadModel) -> PluginInstanceDetail {
|
||||
let action = "reading plugin instance diagnostics";
|
||||
let context = plugin_instance_domain_context(PluginContractDomain::from(instance.domain));
|
||||
let diagnostics = collect_instance_diagnostics(&instance, plugin_instance_operation_block_reason(context, action).await);
|
||||
/// Builds an instance detail view. Callers must run
|
||||
/// [`refresh_persisted_module_switches`] once beforehand.
|
||||
fn plugin_instance_detail(instance: TargetInstanceReadModel) -> PluginInstanceDetail {
|
||||
let domain = PluginContractDomain::from(instance.domain);
|
||||
let module_disabled_reason = module_disabled_block_reason(domain, "reading plugin instance diagnostics");
|
||||
let diagnostics = collect_instance_diagnostics(&instance, module_disabled_reason);
|
||||
let mut mapped = map_instance(instance);
|
||||
mapped.diagnostic_codes = diagnostics.iter().map(|item| item.code.clone()).collect();
|
||||
|
||||
@@ -577,15 +609,20 @@ fn plugin_instance_mutation_block_reason(
|
||||
shared_target_mutation_block_reason(context.specs, context.route_prefix, config, target_type, target_name, target_label)
|
||||
}
|
||||
|
||||
async fn plugin_instance_operation_block_reason(context: PluginInstanceDomainContext, action: &str) -> Option<String> {
|
||||
/// Reloads persisted module switches from the config store. Call once per
|
||||
/// admin request before evaluating [`module_disabled_block_reason`] so that
|
||||
/// list/detail paths do not hit the store once per domain or per instance.
|
||||
async fn refresh_persisted_module_switches() {
|
||||
if let Err(err) = refresh_persisted_module_switches_from_store().await {
|
||||
warn!(
|
||||
error = %err,
|
||||
"failed to reload persisted module switches before checking plugin instance operation gating"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match context.domain {
|
||||
fn module_disabled_block_reason(domain: PluginContractDomain, action: &str) -> Option<String> {
|
||||
match domain {
|
||||
PluginContractDomain::Notify => {
|
||||
refresh_notify_module_enabled();
|
||||
target_module_disabled_reason("notify", rustfs_config::ENV_NOTIFY_ENABLE, is_notify_module_enabled(), action)
|
||||
@@ -597,6 +634,11 @@ async fn plugin_instance_operation_block_reason(context: PluginInstanceDomainCon
|
||||
}
|
||||
}
|
||||
|
||||
async fn plugin_instance_operation_block_reason(context: PluginInstanceDomainContext, action: &str) -> Option<String> {
|
||||
refresh_persisted_module_switches().await;
|
||||
module_disabled_block_reason(context.domain, action)
|
||||
}
|
||||
|
||||
async fn plugin_instance_runtime_statuses(context: PluginInstanceDomainContext) -> S3Result<HashMap<(String, String), String>> {
|
||||
match context.domain {
|
||||
PluginContractDomain::Notify => {
|
||||
@@ -623,7 +665,7 @@ async fn plugin_instance_config_snapshot(context: PluginInstanceDomainContext) -
|
||||
async fn collect_domain_instances(context: PluginInstanceDomainContext) -> S3Result<Vec<PluginInstanceEntry>> {
|
||||
let runtime_statuses = plugin_instance_runtime_statuses(context).await?;
|
||||
let config = plugin_instance_config_snapshot(context).await?;
|
||||
let module_disabled_reason = plugin_instance_operation_block_reason(context, "listing plugin instances").await;
|
||||
let module_disabled_reason = module_disabled_block_reason(context.domain, "listing plugin instances");
|
||||
let mut entries = Vec::new();
|
||||
for instance in collect_target_instances(context.specs, context.route_prefix, &config, runtime_statuses) {
|
||||
entries.push(plugin_instance_list_entry(instance, module_disabled_reason.clone()));
|
||||
@@ -632,6 +674,7 @@ async fn collect_domain_instances(context: PluginInstanceDomainContext) -> S3Res
|
||||
}
|
||||
|
||||
pub(super) async fn collect_all_instances() -> S3Result<Vec<PluginInstanceEntry>> {
|
||||
refresh_persisted_module_switches().await;
|
||||
let (mut notify_instances, audit_instances) = tokio::try_join!(
|
||||
collect_domain_instances(plugin_instance_domain_context(PluginContractDomain::Notify)),
|
||||
collect_domain_instances(plugin_instance_domain_context(PluginContractDomain::Audit))
|
||||
@@ -714,11 +757,12 @@ impl Operation for GetPluginInstanceHandler {
|
||||
.get("id")
|
||||
.ok_or_else(|| s3_error!(InvalidArgument, "missing required parameter: 'id'"))?;
|
||||
|
||||
refresh_persisted_module_switches().await;
|
||||
let instance = find_plugin_instance(instance_id)
|
||||
.await?
|
||||
.ok_or_else(|| s3_error!(NoSuchKey, "plugin instance not found"))?;
|
||||
|
||||
let data = serde_json::to_vec(&plugin_instance_detail(instance).await)
|
||||
let data = serde_json::to_vec(&plugin_instance_detail(instance))
|
||||
.map_err(|e| s3_error!(InternalError, "failed to serialize response: {}", e))?;
|
||||
Ok(build_json_response(StatusCode::OK, Body::from(data), req.headers.get("x-request-id")))
|
||||
}
|
||||
@@ -746,58 +790,40 @@ impl Operation for PutPluginInstanceHandler {
|
||||
.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE)
|
||||
.await
|
||||
.map_err(|_| s3_error!(InvalidRequest, "failed to read request body"))?;
|
||||
let body: PluginInstanceBody = serde_json::from_slice(&body_bytes)
|
||||
let mut body: PluginInstanceBody = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| s3_error!(InvalidArgument, "invalid json body for plugin instance config: {}", e))?;
|
||||
|
||||
match context.domain {
|
||||
PluginContractDomain::Notify => {
|
||||
let (_ns, config_snapshot) = load_notification_config_snapshot().await?;
|
||||
if let Some(reason) = plugin_instance_mutation_block_reason(
|
||||
context,
|
||||
&config_snapshot,
|
||||
resolved.target_spec.subsystem,
|
||||
&resolved.target_name,
|
||||
"plugin instance",
|
||||
) {
|
||||
return Err(s3_error!(InvalidRequest, "{reason}"));
|
||||
}
|
||||
|
||||
let kvs = build_enabled_target_kvs(
|
||||
context.specs,
|
||||
body.key_values.iter().map(|kv| (kv.key.as_str(), kv.value.as_str())),
|
||||
resolved.target_spec.subsystem,
|
||||
context.default_queue_dir,
|
||||
"plugin instance",
|
||||
)
|
||||
.await?;
|
||||
|
||||
set_plugin_instance_config(context, &resolved, kvs).await?;
|
||||
}
|
||||
PluginContractDomain::Audit => {
|
||||
let config_snapshot = load_server_config_from_store().await?;
|
||||
if let Some(reason) = plugin_instance_mutation_block_reason(
|
||||
context,
|
||||
&config_snapshot,
|
||||
resolved.target_spec.subsystem,
|
||||
&resolved.target_name,
|
||||
"plugin instance",
|
||||
) {
|
||||
return Err(s3_error!(InvalidRequest, "{reason}"));
|
||||
}
|
||||
|
||||
let kvs = build_enabled_target_kvs(
|
||||
context.specs,
|
||||
body.key_values.iter().map(|kv| (kv.key.as_str(), kv.value.as_str())),
|
||||
resolved.target_spec.subsystem,
|
||||
context.default_queue_dir,
|
||||
"plugin instance",
|
||||
)
|
||||
.await?;
|
||||
|
||||
set_plugin_instance_config(context, &resolved, kvs).await?;
|
||||
}
|
||||
let config_snapshot = plugin_instance_config_snapshot(context).await?;
|
||||
if let Some(reason) = plugin_instance_mutation_block_reason(
|
||||
context,
|
||||
&config_snapshot,
|
||||
resolved.target_spec.subsystem,
|
||||
&resolved.target_name,
|
||||
"plugin instance",
|
||||
) {
|
||||
return Err(s3_error!(InvalidRequest, "{reason}"));
|
||||
}
|
||||
|
||||
// GET responses redact secret values; a read-modify-write round trip
|
||||
// sends the placeholder back, which must restore the stored secret
|
||||
// instead of persisting the placeholder literal.
|
||||
restore_redacted_secret_values(
|
||||
&mut body.key_values,
|
||||
resolved.target_spec.secret_fields,
|
||||
stored_instance_config(&config_snapshot, resolved.target_spec.subsystem, &resolved.target_name),
|
||||
)?;
|
||||
|
||||
let kvs = build_enabled_target_kvs(
|
||||
context.specs,
|
||||
body.key_values.iter().map(|kv| (kv.key.as_str(), kv.value.as_str())),
|
||||
resolved.target_spec.subsystem,
|
||||
context.default_queue_dir,
|
||||
"plugin instance",
|
||||
)
|
||||
.await?;
|
||||
|
||||
set_plugin_instance_config(context, &resolved, kvs).await?;
|
||||
|
||||
Ok(build_json_response(StatusCode::OK, Body::empty(), req.headers.get("x-request-id")))
|
||||
}
|
||||
}
|
||||
@@ -819,37 +845,19 @@ impl Operation for DeletePluginInstanceHandler {
|
||||
return Err(s3_error!(InvalidRequest, "{reason}"));
|
||||
}
|
||||
|
||||
match context.domain {
|
||||
PluginContractDomain::Notify => {
|
||||
let (_ns, config_snapshot) = load_notification_config_snapshot().await?;
|
||||
if let Some(reason) = plugin_instance_mutation_block_reason(
|
||||
context,
|
||||
&config_snapshot,
|
||||
resolved.target_spec.subsystem,
|
||||
&resolved.target_name,
|
||||
"plugin instance",
|
||||
) {
|
||||
return Err(s3_error!(InvalidRequest, "{reason}"));
|
||||
}
|
||||
|
||||
remove_plugin_instance_config(context, &resolved).await?;
|
||||
}
|
||||
PluginContractDomain::Audit => {
|
||||
let config_snapshot = load_server_config_from_store().await?;
|
||||
if let Some(reason) = plugin_instance_mutation_block_reason(
|
||||
context,
|
||||
&config_snapshot,
|
||||
resolved.target_spec.subsystem,
|
||||
&resolved.target_name,
|
||||
"plugin instance",
|
||||
) {
|
||||
return Err(s3_error!(InvalidRequest, "{reason}"));
|
||||
}
|
||||
|
||||
remove_plugin_instance_config(context, &resolved).await?;
|
||||
}
|
||||
let config_snapshot = plugin_instance_config_snapshot(context).await?;
|
||||
if let Some(reason) = plugin_instance_mutation_block_reason(
|
||||
context,
|
||||
&config_snapshot,
|
||||
resolved.target_spec.subsystem,
|
||||
&resolved.target_name,
|
||||
"plugin instance",
|
||||
) {
|
||||
return Err(s3_error!(InvalidRequest, "{reason}"));
|
||||
}
|
||||
|
||||
remove_plugin_instance_config(context, &resolved).await?;
|
||||
|
||||
Ok(build_json_response(StatusCode::OK, Body::empty(), req.headers.get("x-request-id")))
|
||||
}
|
||||
}
|
||||
@@ -1073,6 +1081,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_round_trip_restores_stored_secret_for_redacted_placeholder() {
|
||||
let mut key_values = vec![
|
||||
super::KeyValue {
|
||||
key: WEBHOOK_ENDPOINT.to_string(),
|
||||
value: "https://example.com/webhook".to_string(),
|
||||
},
|
||||
super::KeyValue {
|
||||
key: WEBHOOK_AUTH_TOKEN.to_string(),
|
||||
value: super::REDACTED_SECRET_VALUE.to_string(),
|
||||
},
|
||||
];
|
||||
let mut stored = KVS::new();
|
||||
stored.insert(WEBHOOK_AUTH_TOKEN.to_string(), "stored-secret-token".to_string());
|
||||
|
||||
super::restore_redacted_secret_values(&mut key_values, &[WEBHOOK_AUTH_TOKEN], Some(&stored))
|
||||
.expect("redacted secret should be restored from stored config");
|
||||
|
||||
assert_eq!(key_values[0].value, "https://example.com/webhook");
|
||||
assert_eq!(key_values[1].value, "stored-secret-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_round_trip_rejects_redacted_placeholder_without_stored_secret() {
|
||||
let mut key_values = vec![super::KeyValue {
|
||||
key: WEBHOOK_AUTH_TOKEN.to_string(),
|
||||
value: super::REDACTED_SECRET_VALUE.to_string(),
|
||||
}];
|
||||
|
||||
let err = super::restore_redacted_secret_values(&mut key_values, &[WEBHOOK_AUTH_TOKEN], None)
|
||||
.expect_err("placeholder without a stored secret must be rejected");
|
||||
assert!(err.to_string().contains("no stored secret"));
|
||||
|
||||
let empty_stored = KVS::new();
|
||||
let err = super::restore_redacted_secret_values(&mut key_values, &[WEBHOOK_AUTH_TOKEN], Some(&empty_stored))
|
||||
.expect_err("placeholder with an empty stored secret must be rejected");
|
||||
assert!(err.to_string().contains("no stored secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_round_trip_leaves_placeholder_on_non_secret_fields_untouched() {
|
||||
let mut key_values = vec![super::KeyValue {
|
||||
key: WEBHOOK_ENDPOINT.to_string(),
|
||||
value: super::REDACTED_SECRET_VALUE.to_string(),
|
||||
}];
|
||||
|
||||
super::restore_redacted_secret_values(&mut key_values, &[WEBHOOK_AUTH_TOKEN], None)
|
||||
.expect("non-secret fields are not subject to placeholder restoration");
|
||||
assert_eq!(key_values[0].value, super::REDACTED_SECRET_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_instance_config_lookup_is_case_insensitive_on_target_name() {
|
||||
let mut stored = KVS::new();
|
||||
stored.insert(WEBHOOK_AUTH_TOKEN.to_string(), "stored-secret-token".to_string());
|
||||
let config = Config(HashMap::from([(
|
||||
NOTIFY_WEBHOOK_SUB_SYS.to_string(),
|
||||
HashMap::from([("Primary".to_string(), stored)]),
|
||||
)]));
|
||||
|
||||
let found = super::stored_instance_config(&config, NOTIFY_WEBHOOK_SUB_SYS, "primary")
|
||||
.expect("case-insensitive lookup should find the stored section");
|
||||
assert_eq!(found.lookup(WEBHOOK_AUTH_TOKEN).as_deref(), Some("stored-secret-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_instance_body_rejects_unknown_fields() {
|
||||
let err = serde_json::from_str::<PluginInstanceBody>(r#"{"key_values":[],"unexpected_field":true}"#)
|
||||
|
||||
@@ -94,6 +94,9 @@ pub(crate) struct AdminTargetSpec {
|
||||
pub subsystem: &'static str,
|
||||
pub service: &'static str,
|
||||
pub valid_keys: &'static [&'static str],
|
||||
/// Config keys whose values are secrets, sourced from the plugin manifest
|
||||
/// so redaction never depends on hand-maintained per-service tables.
|
||||
pub secret_fields: &'static [&'static str],
|
||||
validator: AdminRequestValidatorFn,
|
||||
}
|
||||
|
||||
@@ -103,6 +106,7 @@ pub(crate) fn admin_target_spec_from_builtin(descriptor: &BuiltinTargetAdminDesc
|
||||
subsystem: admin.subsystem(),
|
||||
service: descriptor.manifest().target_type,
|
||||
valid_keys: descriptor.valid_fields(),
|
||||
secret_fields: descriptor.manifest().secret_fields,
|
||||
validator: validator_from_metadata(admin),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,24 +163,24 @@ pub(crate) struct PluginInstallationContract {
|
||||
pub validation_error: Option<String>,
|
||||
}
|
||||
|
||||
impl From<rustfs_targets::TargetPluginRevision> for PluginRevisionContract {
|
||||
fn from(value: rustfs_targets::TargetPluginRevision) -> Self {
|
||||
Self {
|
||||
version: value.version,
|
||||
digest_sha256: value.digest_sha256,
|
||||
source: value.source,
|
||||
installed_at: value.installed_at,
|
||||
artifact_id: value.artifact_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TargetPluginInstallation> for PluginInstallationContract {
|
||||
fn from(value: TargetPluginInstallation) -> Self {
|
||||
Self {
|
||||
install_state: PluginInstallState::from(value.install_state),
|
||||
current_revision: value.current_revision.map(|revision| PluginRevisionContract {
|
||||
version: revision.version,
|
||||
digest_sha256: revision.digest_sha256,
|
||||
source: revision.source,
|
||||
installed_at: revision.installed_at,
|
||||
artifact_id: revision.artifact_id,
|
||||
}),
|
||||
previous_revision: value.previous_revision.map(|revision| PluginRevisionContract {
|
||||
version: revision.version,
|
||||
digest_sha256: revision.digest_sha256,
|
||||
source: revision.source,
|
||||
installed_at: revision.installed_at,
|
||||
artifact_id: revision.artifact_id,
|
||||
}),
|
||||
current_revision: value.current_revision.map(PluginRevisionContract::from),
|
||||
previous_revision: value.previous_revision.map(PluginRevisionContract::from),
|
||||
validation_error: value.validation_error,
|
||||
}
|
||||
}
|
||||
@@ -328,7 +328,7 @@ pub(crate) struct PluginInstanceEntry {
|
||||
pub config: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub operational_state: Option<PluginOperationalStateContract>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub diagnostic_codes: Vec<PluginInstanceDiagnosticCode>,
|
||||
}
|
||||
|
||||
@@ -361,14 +361,14 @@ pub(crate) struct PluginInstanceDiagnosticCount {
|
||||
pub(crate) struct PluginInstanceDetail {
|
||||
#[serde(flatten)]
|
||||
pub instance: PluginInstanceEntry,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub diagnostics: Vec<PluginInstanceDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct PluginInstancesResponse {
|
||||
pub instances: Vec<PluginInstanceEntry>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub diagnostic_counts: Vec<PluginInstanceDiagnosticCount>,
|
||||
pub truncated: bool,
|
||||
pub next_marker: Option<String>,
|
||||
|
||||
Reference in New Issue
Block a user