feat(mq): implement Apache Pulsar message queue admin

* feat(mq): implement Apache Pulsar message queue admin

- Add complete MQ admin adapter layer with version detection
  * Pulsar adapter with multi-version API support
  * OAuth2 token caching and authentication
  * JWT token signing for Pulsar clients
  * Transport layer (SSH/Proxy) integration

- Implement comprehensive admin operations
  * Tenant, namespace, topic management
  * Subscription and consumer/producer monitoring
  * Rate limits, backlog quotas, retention policies
  * Permissions and role-based access control
  * Raw admin API for advanced operations

- Add desktop UI components
  * MQ admin console with tabbed interface
  * Tenant, namespace, topic panels
  * Subscription management and cursor operations
  * Monitoring panel with stats visualization
  * Policy configuration UI

- Add web routes for MQ admin operations
- Add secret store support for MQ auth credentials
- Add comprehensive test coverage

* fix: del local test files

* fix(mq): mark message queue as unsupported mcpMode in driver manifest

MQ admin connections do not use the MCP query bridge - they use a
separate REST admin API. Mark mcpMode as 'unsupported' to align with
the test expectations and reflect the actual architecture.

Fixes CI test: driver-manifest.test.ts

* fix(mq): fix TypeScript errors in MQ components

- Fix typo in SubscriptionsPanel.vue: messagformatError(e)Id -> message.messageId
- Use formatError() helper in RawApiPanel.vue instead of accessing e?.message
- Fix undefined 'tab' reference in ConnectionTree.vue: use config.db_type instead

Fixes frontend CI TypeScript check errors.

* fix(mcp): add 'mq' to MCP server connection type description

The MCP server's connection type description must include all database
types from the driver manifest. Add 'mq' to the list to fix the test:
'add connection database type description includes every manifest database type'

Fixes: packages/mcp-server/tests/driver-manifest.test.ts

* fix(mq): add missing ConnectionConfig fields for Rust compilation

- Add gbase_server and keepalive_interval_secs fields to all ConnectionConfig
  test constructors in mq/config.rs, mq/service.rs, and storage.rs
- Import uses_bare_mysql_pool in connection.rs tests module

Fixes compilation errors from upstream ConnectionConfig changes.

* chore(ui): simplify Pulsar icon to logo-only version

Remove text from Pulsar icon (127x30 -> 35x30), keeping only
the wave logo symbol for a cleaner, more consistent appearance
with other database icons.

* fix(mq): return removed pools from drain_connection_pools

---------

Co-authored-by: t8y2 <1156263951@qq.com>
This commit is contained in:
miracle
2026-06-16 21:57:17 +08:00
committed by GitHub
co-authored by t8y2
parent 8339d23df5
commit 2a59d3143e
83 changed files with 15979 additions and 37 deletions
+3 -1
View File
@@ -5,8 +5,9 @@ edition = "2021"
license = "Apache-2.0"
[features]
default = ["duckdb-bundled"]
default = ["duckdb-bundled", "mq-admin"]
duckdb-bundled = ["duckdb/bundled"]
mq-admin = []
[dependencies]
serde = { version = "1.0", features = ["derive"] }
@@ -44,6 +45,7 @@ portpicker = "0.1.1"
csv = "1"
calamine = "0.30.1"
base64 = "0.22"
jsonwebtoken = "9"
aes-gcm = "0.10"
argon2 = "0.5"
sha2 = "0.10"
@@ -1462,6 +1462,35 @@
"driverManagement": true
}
},
{
"dbType": "mq",
"label": "Message Queue",
"runtimeMode": "native",
"mcpMode": "unsupported",
"singleConnectionPool": false,
"metadataConnectionScoped": false,
"skipTcpProbe": true,
"defaultPort": 8080,
"supportLevel": "connect",
"capabilities": {
"queryExecution": false,
"metadataBrowse": false,
"objectBrowser": false,
"objectSource": false,
"schemaSearch": false,
"diagram": false,
"tableDataEdit": false,
"tableStructureEdit": false,
"tableImport": false,
"dataTransfer": false,
"sqlFileExecution": false,
"databaseCreate": false,
"fieldLineage": false,
"sqlExplain": false,
"userAdmin": false,
"driverManagement": false
}
},
{
"dbType": "iotdb",
"label": "Apache IoTDB",
+41
View File
@@ -10,6 +10,9 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::ai::AiConfig;
use crate::connection_secrets::{
MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY,
};
use crate::models::connection::{ConnectionConfig, TransportLayerConfig};
use crate::saved_sql::SavedSqlLibrary;
use crate::storage::{DesktopSettings, Storage};
@@ -23,6 +26,11 @@ const SECRET_KEYS: &[&str] = &[
"proxy_password",
"redis_sentinel_password",
"connection_string",
MQ_AUTH_TOKEN_KEY,
MQ_AUTH_PASSWORD_KEY,
MQ_AUTH_API_KEY_VALUE_KEY,
MQ_AUTH_CLIENT_SECRET_KEY,
MQ_TOKEN_SIGNING_KEY,
];
const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels.";
const TRANSPORT_LAYER_SECRET_PREFIX: &str = "transport_layers.";
@@ -362,11 +370,44 @@ async fn build_sensitive_payload(
if let Some(connection_string) = &config.connection_string {
push_secret(&mut connection_secrets, &config.id, "connection_string", connection_string);
}
push_mq_external_config_secrets(&mut connection_secrets, config);
}
Ok(SensitiveSyncPayload { connection_secrets, ai_config: storage.load_ai_config().await? })
}
fn push_mq_external_config_secrets(secrets: &mut Vec<ConnectionSecretSnapshot>, config: &ConnectionConfig) {
let Some(external_config) = config.external_config.as_ref() else {
return;
};
if let Some(auth) = external_config.get("auth").and_then(serde_json::Value::as_object) {
match auth.get("kind").and_then(serde_json::Value::as_str) {
Some("token") => push_json_secret(secrets, &config.id, MQ_AUTH_TOKEN_KEY, auth, "token"),
Some("basic") => push_json_secret(secrets, &config.id, MQ_AUTH_PASSWORD_KEY, auth, "password"),
Some("apiKey") | Some("api_key") | Some("apikey") => {
push_json_secret(secrets, &config.id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value")
}
Some("oauth2") => push_json_secret(secrets, &config.id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret"),
_ => {}
}
}
if let Some(signing) = external_config.get("tokenSigning").and_then(serde_json::Value::as_object) {
push_json_secret(secrets, &config.id, MQ_TOKEN_SIGNING_KEY, signing, "key");
}
}
fn push_json_secret(
secrets: &mut Vec<ConnectionSecretSnapshot>,
connection_id: &str,
key: &str,
object: &serde_json::Map<String, serde_json::Value>,
field: &str,
) {
if let Some(secret) = object.get(field).and_then(serde_json::Value::as_str) {
push_secret(secrets, connection_id, key, secret);
}
}
fn push_secret(secrets: &mut Vec<ConnectionSecretSnapshot>, connection_id: &str, key: &str, secret: &str) {
if secret.is_empty() {
return;
+121 -4
View File
@@ -70,7 +70,14 @@ pub enum PoolKind {
InfluxDb(db::influxdb_driver::InfluxdbClient),
Agent(Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>),
ExternalTabular(ExternalTabularHandle),
ExternalDriver { driver_id: String, config: Arc<ConnectionConfig>, session: Arc<PluginDriverSession> },
ExternalDriver {
driver_id: String,
config: Arc<ConnectionConfig>,
session: Arc<PluginDriverSession>,
},
/// Message queue admin connection (not a data query pool; serves as a
/// marker that this connection_id is a valid MQ admin connection).
MessageQueue,
}
pub struct AppState {
@@ -83,6 +90,8 @@ pub struct AppState {
pub storage: Storage,
pub plugins: PluginRegistry,
pub agent_manager: crate::agent_manager::AgentManager,
#[cfg(feature = "mq-admin")]
pub mq_registry: crate::mq::MqAdminRegistry,
}
pub fn metadata_connection_config(config: &ConnectionConfig) -> ConnectionConfig {
@@ -263,6 +272,8 @@ impl AppState {
agent_dir,
app_version,
),
#[cfg(feature = "mq-admin")]
mq_registry: crate::mq::MqAdminRegistry::new(),
}
}
@@ -743,6 +754,23 @@ impl AppState {
}
self.external_driver_pool("jdbc", &jdbc_config).await?
}
#[cfg(feature = "mq-admin")]
DatabaseType::MessageQueue => {
// MQ admin connections don't hold a data query pool. We just test
// connectivity via the mq_registry and insert a marker so this
// connection_id is recognized as valid.
let mqc = self.mq_admin_config_for_connection(connection_id, &config).await?;
let adapter = self.mq_registry.build_transient_config(mqc).await?;
adapter.test_connection().await?;
PoolKind::MessageQueue
}
#[cfg(not(feature = "mq-admin"))]
DatabaseType::MessageQueue => {
return Err(
"Message queue admin support is not compiled in this build. Rebuild with the 'mq-admin' feature."
.to_string(),
);
}
};
self.insert_connection_pool(pool_key.clone(), pool, &db_config).await;
@@ -773,6 +801,21 @@ impl AppState {
Ok(("127.0.0.1".to_string(), local_port))
}
#[cfg(feature = "mq-admin")]
pub async fn mq_admin_config_for_connection(
&self,
connection_id: &str,
config: &ConnectionConfig,
) -> Result<crate::mq::config::MqAdminConfig, String> {
let mqc = crate::mq::config::MqAdminConfig::from_connection(config)?;
if !config.has_effective_transport_layers() {
return Ok(mqc);
}
let (host, port) = self.connection_host_port(connection_id, config).await?;
Ok(mqc.with_connect_override(&host, port))
}
async fn remove_stale_connection_pool(&self, pool_key: &str) -> bool {
let stale = {
let connections = self.connections.read().await;
@@ -1104,6 +1147,10 @@ impl AppState {
removed.push((key, pool));
}
}
drop(conns);
// Also drop the MQ admin adapter if this is an MQ connection.
#[cfg(feature = "mq-admin")]
self.mq_registry.drop_connection(connection_id).await;
removed
}
@@ -1183,11 +1230,30 @@ fn connection_remote_endpoint(config: &ConnectionConfig) -> (String, u16) {
.filter(|s| !s.is_empty())
.and_then(parse_jdbc_host_port)
.unwrap_or_else(|| (config.host.clone(), config.port))
} else if config.db_type == DatabaseType::MessageQueue {
parse_mq_admin_host_port(config).unwrap_or_else(|| (config.host.clone(), config.port))
} else {
(config.host.clone(), config.port)
}
}
fn parse_mq_admin_host_port(config: &ConnectionConfig) -> Option<(String, u16)> {
let value = config
.external_config
.as_ref()?
.get("adminUrl")
.or_else(|| config.external_config.as_ref()?.get("admin_url"))?
.as_str()?
.trim();
if value.is_empty() {
return None;
}
let url = reqwest::Url::parse(value).ok()?;
let host = url.host_str()?.to_string();
let port = url.port_or_known_default()?;
Some((host, port))
}
fn normalize_client_session_id(client_session_id: Option<&str>) -> Option<String> {
client_session_id.map(str::trim).filter(|session| !session.is_empty()).map(|session| session.replace(':', "_"))
}
@@ -1257,6 +1323,7 @@ pub async fn close_pool_kind(pool: PoolKind) {
}
PoolKind::ExternalTabular(_) => {}
PoolKind::ExternalDriver { .. } => {}
PoolKind::MessageQueue => {}
}
}
@@ -1513,9 +1580,9 @@ async fn detect_ob_oracle_mode(config: &ConnectionConfig, pool: &db::mysql::MySq
#[cfg(test)]
mod tests {
use super::{
agent_connect_timeout, connection_url_for_endpoint, database_connection_config, metadata_connection_config,
mysql_metadata_fallback_url, redacted_connection_url_for_endpoint, uses_bare_mysql_pool, uses_tcp_probe,
validate_h2_database_path, AppState, PoolKind,
agent_connect_timeout, connection_remote_endpoint, connection_url_for_endpoint, database_connection_config,
metadata_connection_config, mysql_metadata_fallback_url, redacted_connection_url_for_endpoint,
uses_bare_mysql_pool, uses_tcp_probe, validate_h2_database_path, AppState, PoolKind,
};
use crate::agent_connection::{
agent_connect_params, mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver,
@@ -2420,6 +2487,56 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn mq_remote_endpoint_comes_from_admin_url_when_host_fields_are_empty() {
let mut config = mysql_config(None);
config.db_type = DatabaseType::MessageQueue;
config.host = String::new();
config.port = 0;
config.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "https://broker.internal:8443/pulsar-admin?tenant=public",
"auth": { "kind": "none" }
}));
assert_eq!(connection_remote_endpoint(&config), ("broker.internal".to_string(), 8443));
}
#[cfg(feature = "mq-admin")]
#[tokio::test]
async fn mq_admin_config_preserves_admin_url_and_uses_forwarded_connect_override() {
let (state, dir) = test_app_state().await;
let mut config = mysql_config(None);
config.id = "proxied-mq".to_string();
config.db_type = DatabaseType::MessageQueue;
config.host = String::new();
config.port = 0;
config.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "https://broker.internal:8443/pulsar-admin?tenant=public",
"auth": { "kind": "none" }
}));
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
id: "proxy".to_string(),
name: String::new(),
enabled: true,
proxy_type: ProxyType::Socks5,
host: "127.0.0.1".to_string(),
port: 65000,
username: String::new(),
password: String::new(),
})];
let mqc = state.mq_admin_config_for_connection("proxied-mq", &config).await.unwrap();
assert_eq!(mqc.admin_url, "https://broker.internal:8443/pulsar-admin?tenant=public");
let connect_override = mqc.connect_override.expect("MQ transport should set a connect override");
assert_eq!(connect_override.host, "127.0.0.1");
assert_ne!(connect_override.port, 8443);
state.proxy_tunnels.stop_tunnel("proxied-mq:transport:0").await;
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
#[ignore = "requires a reachable GaussDB instance via environment variables"]
async fn live_gaussdb_native_connection_succeeds() {
+365 -3
View File
@@ -1,4 +1,4 @@
use crate::models::connection::{ConnectionConfig, TransportLayerConfig};
use crate::models::connection::{ConnectionConfig, DatabaseType, TransportLayerConfig};
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
@@ -11,6 +11,13 @@ pub const TRANSPORT_LAYER_SECRET_PREFIX: &str = "transport_layers.";
pub const PROXY_PASSWORD_KEY: &str = "proxy_password";
pub const REDIS_SENTINEL_PASSWORD_KEY: &str = "redis_sentinel_password";
pub const CONNECTION_STRING_KEY: &str = "connection_string";
pub const MQ_AUTH_SECRET_PREFIX: &str = "mq.auth.";
pub const MQ_AUTH_TOKEN_KEY: &str = "mq.auth.token";
pub const MQ_AUTH_PASSWORD_KEY: &str = "mq.auth.password";
pub const MQ_AUTH_API_KEY_VALUE_KEY: &str = "mq.auth.api_key_value";
pub const MQ_AUTH_CLIENT_SECRET_KEY: &str = "mq.auth.client_secret";
pub const MQ_TOKEN_SIGNING_SECRET_PREFIX: &str = "mq.token_signing.";
pub const MQ_TOKEN_SIGNING_KEY: &str = "mq.token_signing.key";
pub trait ConnectionSecretStore {
fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String>;
@@ -31,7 +38,23 @@ impl FileSecretStore {
}
fn read_store(&self) -> HashMap<String, String> {
std::fs::read_to_string(&self.path).ok().and_then(|json| serde_json::from_str(&json).ok()).unwrap_or_default()
match std::fs::read_to_string(&self.path) {
Ok(json) => match serde_json::from_str(&json) {
Ok(map) => map,
Err(e) => {
log::warn!(
"Failed to parse secret store at {:?}: {}. Returning empty store. This may indicate file corruption.",
self.path, e
);
HashMap::default()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => HashMap::default(),
Err(e) => {
log::warn!("Failed to read secret store at {:?}: {}. Returning empty store.", self.path, e);
HashMap::default()
}
}
}
fn write_store(&self, map: &HashMap<String, String>) -> Result<(), String> {
@@ -79,6 +102,8 @@ pub fn save_connections_to_file(
}
persist_secret(store, &config.id, REDIS_SENTINEL_PASSWORD_KEY, &config.redis_sentinel_password)?;
persist_optional_secret(store, &config.id, CONNECTION_STRING_KEY, config.connection_string.as_deref())?;
persist_mq_auth_secrets(store, config)?;
persist_mq_token_signing_secret(store, config)?;
// New configs persist transport-layer secrets only. Remove legacy transport secret slots after the
// migrated layer values have been written so old configs do not keep two sources of truth.
@@ -133,6 +158,8 @@ pub fn load_connections_from_file(
}
}
}
hydrate_mq_auth_secrets(store, config, &mut needs_rewrite)?;
hydrate_mq_token_signing_secret(store, config, &mut needs_rewrite)?;
}
if needs_rewrite {
@@ -294,6 +321,8 @@ fn delete_removed_connection_secrets(
delete_secret_prefix(store, &config.id, SSH_TUNNEL_SECRET_PREFIX)?;
delete_secret_prefix(store, &config.id, TRANSPORT_LAYER_SECRET_PREFIX)?;
store.delete_secret(&config.id, CONNECTION_STRING_KEY)?;
delete_secret_prefix(store, &config.id, MQ_AUTH_SECRET_PREFIX)?;
delete_secret_prefix(store, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
}
Ok(())
}
@@ -331,6 +360,193 @@ fn delete_secret_prefix(
store.delete_secret_prefix(connection_id, key_prefix)
}
fn persist_mq_auth_secrets(store: &dyn ConnectionSecretStore, config: &ConnectionConfig) -> Result<(), String> {
if config.db_type != DatabaseType::MessageQueue {
delete_secret_prefix(store, &config.id, MQ_AUTH_SECRET_PREFIX)?;
return Ok(());
}
let Some(auth) = mq_auth_object(config.external_config.as_ref()) else {
delete_secret_prefix(store, &config.id, MQ_AUTH_SECRET_PREFIX)?;
return Ok(());
};
match mq_auth_kind(auth).as_deref() {
Some("none") => delete_secret_prefix(store, &config.id, MQ_AUTH_SECRET_PREFIX)?,
Some("token") => replace_mq_auth_secret(store, &config.id, MQ_AUTH_TOKEN_KEY, auth, "token")?,
Some("basic") => replace_mq_auth_secret(store, &config.id, MQ_AUTH_PASSWORD_KEY, auth, "password")?,
Some("apiKey") | Some("api_key") | Some("apikey") => {
replace_mq_auth_secret(store, &config.id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value")?
}
Some("oauth2") => replace_mq_auth_secret(store, &config.id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret")?,
_ => delete_secret_prefix(store, &config.id, MQ_AUTH_SECRET_PREFIX)?,
}
Ok(())
}
fn replace_mq_auth_secret(
store: &dyn ConnectionSecretStore,
connection_id: &str,
key: &str,
auth: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<(), String> {
let current = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty());
let existing = if current.is_none() { store.get_secret(connection_id, key)? } else { None };
delete_secret_prefix(store, connection_id, MQ_AUTH_SECRET_PREFIX)?;
match current {
Some(secret) => store.set_secret(connection_id, key, secret),
None => match existing {
Some(secret) => store.set_secret(connection_id, key, &secret),
None => Ok(()),
},
}
}
fn persist_mq_token_signing_secret(store: &dyn ConnectionSecretStore, config: &ConnectionConfig) -> Result<(), String> {
if config.db_type != DatabaseType::MessageQueue {
delete_secret_prefix(store, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
return Ok(());
}
let Some(signing) = mq_token_signing_object(config.external_config.as_ref()) else {
delete_secret_prefix(store, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
return Ok(());
};
persist_json_secret_if_present(store, &config.id, MQ_TOKEN_SIGNING_KEY, signing, "key")
}
fn hydrate_mq_auth_secrets(
store: &dyn ConnectionSecretStore,
config: &mut ConnectionConfig,
needs_rewrite: &mut bool,
) -> Result<(), String> {
if config.db_type != DatabaseType::MessageQueue {
return Ok(());
}
let Some(auth) = mq_auth_object_mut(config.external_config.as_mut()) else {
return Ok(());
};
match mq_auth_kind(auth).as_deref() {
Some("token") => hydrate_json_secret(store, &config.id, MQ_AUTH_TOKEN_KEY, auth, "token", needs_rewrite)?,
Some("basic") => hydrate_json_secret(store, &config.id, MQ_AUTH_PASSWORD_KEY, auth, "password", needs_rewrite)?,
Some("apiKey") | Some("api_key") | Some("apikey") => {
hydrate_json_secret(store, &config.id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value", needs_rewrite)?
}
Some("oauth2") => {
hydrate_json_secret(store, &config.id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret", needs_rewrite)?
}
_ => {}
}
Ok(())
}
fn hydrate_mq_token_signing_secret(
store: &dyn ConnectionSecretStore,
config: &mut ConnectionConfig,
needs_rewrite: &mut bool,
) -> Result<(), String> {
if config.db_type != DatabaseType::MessageQueue {
return Ok(());
}
let Some(signing) = mq_token_signing_object_mut(config.external_config.as_mut()) else {
return Ok(());
};
hydrate_json_secret(store, &config.id, MQ_TOKEN_SIGNING_KEY, signing, "key", needs_rewrite)
}
fn persist_json_secret_if_present(
store: &dyn ConnectionSecretStore,
connection_id: &str,
key: &str,
auth: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<(), String> {
match auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()) {
Some(secret) => store.set_secret(connection_id, key, secret),
None => Ok(()),
}
}
fn hydrate_json_secret(
store: &dyn ConnectionSecretStore,
connection_id: &str,
key: &str,
auth: &mut serde_json::Map<String, serde_json::Value>,
field: &str,
needs_rewrite: &mut bool,
) -> Result<(), String> {
match auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()) {
Some(secret) => {
store.set_secret(connection_id, key, secret)?;
*needs_rewrite = true;
}
None => {
if let Some(secret) = store.get_secret(connection_id, key)? {
auth.insert(field.to_string(), serde_json::Value::String(secret));
}
}
}
Ok(())
}
fn scrub_mq_auth_secrets(config: &mut ConnectionConfig) {
let Some(auth) = mq_auth_object_mut(config.external_config.as_mut()) else {
return;
};
match mq_auth_kind(auth).as_deref() {
Some("token") => scrub_json_secret(auth, "token"),
Some("basic") => scrub_json_secret(auth, "password"),
Some("apiKey") | Some("api_key") | Some("apikey") => scrub_json_secret(auth, "value"),
Some("oauth2") => scrub_json_secret(auth, "clientSecret"),
_ => {}
}
}
fn scrub_mq_token_signing_secret(config: &mut ConnectionConfig) {
let Some(signing) = mq_token_signing_object_mut(config.external_config.as_mut()) else {
return;
};
scrub_json_secret(signing, "key");
}
fn scrub_json_secret(auth: &mut serde_json::Map<String, serde_json::Value>, field: &str) {
if auth.contains_key(field) {
auth.insert(field.to_string(), serde_json::Value::String(String::new()));
}
}
fn mq_auth_kind(auth: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
auth.get("kind").and_then(serde_json::Value::as_str).map(ToString::to_string)
}
fn mq_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
value?.get("auth")?.as_object()
}
fn mq_auth_object_mut(
value: Option<&mut serde_json::Value>,
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
value?.get_mut("auth")?.as_object_mut()
}
fn mq_token_signing_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
value?.get("tokenSigning")?.as_object()
}
fn mq_token_signing_object_mut(
value: Option<&mut serde_json::Value>,
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
value?.get_mut("tokenSigning")?.as_object_mut()
}
fn ssh_tunnel_secret_segment(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String {
if hop.id.trim().is_empty() {
index.to_string()
@@ -398,6 +614,8 @@ fn sanitize_connections(configs: &[ConnectionConfig]) -> Vec<ConnectionConfig> {
}
config.redis_sentinel_password.clear();
config.connection_string = None;
scrub_mq_auth_secrets(&mut config);
scrub_mq_token_signing_secret(&mut config);
config
})
.collect()
@@ -411,7 +629,8 @@ pub fn secret_account(connection_id: &str, key: &str) -> String {
mod tests {
use super::{
load_connections_from_file, save_connections_to_file, ConnectionSecretStore, CONNECTION_STRING_KEY,
MAIN_PASSWORD_KEY, REDIS_SENTINEL_PASSWORD_KEY, SSH_PASSWORD_KEY,
MAIN_PASSWORD_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, REDIS_SENTINEL_PASSWORD_KEY,
SSH_PASSWORD_KEY,
};
use crate::models::connection::{ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig};
use std::cell::RefCell;
@@ -669,4 +888,147 @@ mod tests {
let loaded = load_connections_from_file(&path, &store).unwrap();
assert_eq!(loaded[0].connection_string.as_deref(), Some("mongodb://user:secret@localhost/app"));
}
#[test]
fn save_connections_moves_mq_auth_secrets_to_secret_store_and_restores_them() {
let path = temp_connections_file("mq-auth");
let store = MemorySecretStore::default();
let mut config = connection("pulsar", "", "");
config.db_type = DatabaseType::MessageQueue;
config.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://localhost:8080",
"auth": {
"kind": "token",
"token": "mq-token-secret"
}
}));
save_connections_to_file(&path, &[config], &store).unwrap();
assert_eq!(store.get_existing("pulsar", "mq.auth.token").as_deref(), Some("mq-token-secret"));
let persisted_json = std::fs::read_to_string(&path).unwrap();
assert!(!persisted_json.contains("mq-token-secret"));
let loaded = load_connections_from_file(&path, &store).unwrap();
let auth = loaded[0].external_config.as_ref().and_then(|value| value.get("auth")).expect("restored MQ auth");
assert_eq!(auth.get("token").and_then(serde_json::Value::as_str), Some("mq-token-secret"));
}
#[test]
fn save_connections_moves_mq_basic_and_oauth_secrets_to_secret_store_and_restores_them() {
let path = temp_connections_file("mq-auth-multiple");
let store = MemorySecretStore::default();
let mut basic = connection("basic", "", "");
basic.db_type = DatabaseType::MessageQueue;
basic.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://localhost:8080",
"auth": {
"kind": "basic",
"username": "admin",
"password": "basic-secret"
}
}));
let mut oauth = connection("oauth", "", "");
oauth.db_type = DatabaseType::MessageQueue;
oauth.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://localhost:8080",
"auth": {
"kind": "oauth2",
"issuerUrl": "https://issuer/token",
"clientId": "client",
"clientSecret": "oauth-secret"
}
}));
save_connections_to_file(&path, &[basic, oauth], &store).unwrap();
assert_eq!(store.get_existing("basic", "mq.auth.password").as_deref(), Some("basic-secret"));
assert_eq!(store.get_existing("oauth", "mq.auth.client_secret").as_deref(), Some("oauth-secret"));
let persisted_json = std::fs::read_to_string(&path).unwrap();
assert!(!persisted_json.contains("basic-secret"));
assert!(!persisted_json.contains("oauth-secret"));
let loaded = load_connections_from_file(&path, &store).unwrap();
let basic_auth = loaded[0].external_config.as_ref().and_then(|value| value.get("auth")).unwrap();
let oauth_auth = loaded[1].external_config.as_ref().and_then(|value| value.get("auth")).unwrap();
assert_eq!(basic_auth.get("password").and_then(serde_json::Value::as_str), Some("basic-secret"));
assert_eq!(oauth_auth.get("clientSecret").and_then(serde_json::Value::as_str), Some("oauth-secret"));
}
#[test]
fn save_connections_preserves_existing_mq_secret_when_config_is_sanitized() {
let path = temp_connections_file("mq-auth-preserve");
let store = MemorySecretStore::default();
store.set_existing("pulsar", "mq.auth.token", "existing-token");
let mut config = connection("pulsar", "", "");
config.db_type = DatabaseType::MessageQueue;
config.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://localhost:8080",
"auth": {
"kind": "token",
"token": ""
}
}));
save_connections_to_file(&path, &[config], &store).unwrap();
assert_eq!(store.get_existing("pulsar", "mq.auth.token").as_deref(), Some("existing-token"));
let loaded = load_connections_from_file(&path, &store).unwrap();
let auth = loaded[0].external_config.as_ref().and_then(|value| value.get("auth")).unwrap();
assert_eq!(auth.get("token").and_then(serde_json::Value::as_str), Some("existing-token"));
}
#[test]
fn save_connections_deletes_stale_mq_auth_secrets_when_kind_changes() {
let path = temp_connections_file("mq-auth-kind-change");
let store = MemorySecretStore::default();
store.set_existing("pulsar", MQ_AUTH_TOKEN_KEY, "old-token");
let mut config = connection("pulsar", "", "");
config.db_type = DatabaseType::MessageQueue;
config.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://localhost:8080",
"auth": {
"kind": "basic",
"username": "admin",
"password": "basic-secret"
}
}));
save_connections_to_file(&path, &[config], &store).unwrap();
assert_eq!(store.get_existing("pulsar", MQ_AUTH_TOKEN_KEY), None);
assert_eq!(store.get_existing("pulsar", MQ_AUTH_PASSWORD_KEY).as_deref(), Some("basic-secret"));
}
#[test]
fn save_connections_moves_mq_token_signing_key_to_secret_store_and_restores_it() {
let path = temp_connections_file("mq-token-signing");
let store = MemorySecretStore::default();
let mut config = connection("pulsar", "", "");
config.db_type = DatabaseType::MessageQueue;
config.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://localhost:8080",
"auth": { "kind": "none" },
"tokenSigning": {
"algorithm": "hs256",
"key": "broker-signing-secret"
}
}));
save_connections_to_file(&path, &[config], &store).unwrap();
assert_eq!(store.get_existing("pulsar", MQ_TOKEN_SIGNING_KEY).as_deref(), Some("broker-signing-secret"));
let persisted_json = std::fs::read_to_string(&path).unwrap();
assert!(!persisted_json.contains("broker-signing-secret"));
let loaded = load_connections_from_file(&path, &store).unwrap();
let signing = loaded[0].external_config.as_ref().and_then(|value| value.get("tokenSigning")).unwrap();
assert_eq!(signing.get("key").and_then(serde_json::Value::as_str), Some("broker-signing-secret"));
}
}
+2
View File
@@ -25,6 +25,8 @@ pub mod history;
pub mod jdbc;
pub mod models;
pub mod mongo_ops;
#[cfg(feature = "mq-admin")]
pub mod mq;
pub mod object_source_sql;
pub mod path_utils;
pub mod plugins;
+18 -1
View File
@@ -2,7 +2,7 @@ use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ConnectionConfig {
pub id: String,
pub name: String,
@@ -301,6 +301,10 @@ pub enum DatabaseType {
#[serde(rename = "questdb")]
Questdb,
Jdbc,
/// Message queue admin connection (Pulsar / Kafka / RocketMQ). The specific
/// system is determined by `external_config.systemKind`.
#[serde(rename = "mq")]
MessageQueue,
}
#[derive(Deserialize)]
@@ -768,6 +772,7 @@ impl ConnectionConfig {
format!("{scheme}://{host}:{port}")
}
DatabaseType::Jdbc => "jdbc:<redacted>".to_string(),
DatabaseType::MessageQueue => self.message_queue_admin_url(),
}
}
@@ -968,9 +973,21 @@ impl ConnectionConfig {
DatabaseType::Jdbc => {
self.connection_string.as_deref().filter(|value| !value.is_empty()).unwrap_or("jdbc:").to_string()
}
DatabaseType::MessageQueue => self.message_queue_admin_url(),
}
}
fn message_queue_admin_url(&self) -> String {
self.external_config
.as_ref()
.and_then(|value| value.get("adminUrl").or_else(|| value.get("admin_url")))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("mq://")
.to_string()
}
fn normalized_url_params(&self) -> String {
let value = self.url_params.as_deref().unwrap_or("").trim();
if self.needs_bare_mysql() {
+54
View File
@@ -0,0 +1,54 @@
# Message Queue Admin Module
Apache Pulsar 管理 API 的 Rust 实现。
## 模块结构
```
mq/
├── mod.rs - MqAdminRegistry 注册表
├── types.rs - 类型定义
├── auth.rs - 认证机制 (OAuth2 缓存)
├── config.rs - 配置解析
├── port.rs - MessageQueueAdmin trait
├── service.rs - 服务层函数
└── adapters/
├── pulsar.rs - Pulsar 实现
└── pulsar_version.rs - 版本探测
```
## 核心 Trait
```rust
#[async_trait]
pub trait MessageQueueAdmin: Send + Sync {
async fn list_tenants(&self) -> Result<Vec<TenantInfo>, String>;
async fn list_namespaces(&self, tenant: &str) -> Result<Vec<NamespaceInfo>, String>;
async fn list_topics(&self, ns: &NamespaceRef, opts: ListTopicsOpts) -> Result<Vec<TopicInfo>, String>;
// ... 45 more methods
}
```
## 使用示例
```rust
// AppState 会在存在 SSH/Proxy transport layer 时,把 Admin URL 改写到本地转发端点。
let config = state.mq_admin_config_for_connection(&connection.id, &connection).await?;
let adapter = state.mq_registry.get_or_build_config(&connection.id, config).await?;
let tenants = adapter.list_tenants().await?;
```
通常业务代码优先复用 `service.rs` 中的 `mq_*_core` 函数;只有需要直接组合底层能力时才访问 `MqAdminRegistry`。
## Feature Flag
```toml
[features]
default = ["mq-admin"]
mq-admin = []
```
## 文档
完整文档请参考:`docs/mq-index.md`
+4
View File
@@ -0,0 +1,4 @@
//! Concrete message queue admin adapters.
pub mod pulsar;
pub mod pulsar_version;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,584 @@
//! Pulsar version detection and the version "profile" that isolates all
//! version-specific behaviour (endpoint paths + response parsing + capability
//! flags) behind one object. Adapter methods express *what* to do; the profile
//! decides *how*, per the resolved version.
//!
//! First-party scope is **Pulsar 3.1.x** (REST-contract compatible with the
//! 3.0.x LTS line). Newer versions are handled additively: add a `PulsarVersion`
//! variant, add the differing path/parse branches here, and the adapter, service
//! layer, commands, routes, and frontend stay untouched. See
//! `docs/message-queue-admin-design.zh-CN.md` chapter 10.
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use serde::{Deserialize, Serialize};
use super::super::types::{MqCapabilities, SubscriptionInfo, TopicStats};
/// A recognised Pulsar major/minor line. Unknown versions fall back to the
/// nearest known profile in conservative mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PulsarVersion {
/// Pulsar 3.0.x / 3.1.x — the first-party baseline.
V3_1,
}
impl PulsarVersion {
pub fn label(self) -> &'static str {
match self {
PulsarVersion::V3_1 => "3.1.x",
}
}
}
/// How the version was determined. Surfaced to the UI so users understand
/// whether the adapter is running on a confirmed version.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VersionDetection {
/// Probed from `brokers/version`.
Probed,
/// Pinned by the user in connection config.
Pinned,
/// Could not determine; using the default baseline profile.
Fallback,
}
impl VersionDetection {
pub fn as_str(self) -> &'static str {
match self {
VersionDetection::Probed => "probed",
VersionDetection::Pinned => "pinned",
VersionDetection::Fallback => "fallback",
}
}
}
/// Resolved version picture: which version, how we found it, and the raw string.
#[derive(Debug, Clone)]
pub struct DetectedVersion {
pub version: PulsarVersion,
pub detection: VersionDetection,
pub raw: Option<String>,
}
/// The version profile: a single object that answers every version-dependent
/// question. Adapter methods route all path construction and response parsing
/// through here.
#[derive(Debug, Clone)]
pub struct PulsarApiProfile {
version: PulsarVersion,
detection: VersionDetection,
raw_version: Option<String>,
}
impl PulsarApiProfile {
/// Resolve a profile from a detected version.
pub fn resolve(detected: DetectedVersion) -> Self {
Self { version: detected.version, detection: detected.detection, raw_version: detected.raw }
}
/// The default baseline profile (3.1.x), used when detection fails.
pub fn default_baseline() -> Self {
Self { version: PulsarVersion::V3_1, detection: VersionDetection::Fallback, raw_version: None }
}
pub fn version(&self) -> PulsarVersion {
self.version
}
pub fn detection(&self) -> VersionDetection {
self.detection
}
pub fn raw_version(&self) -> Option<&str> {
self.raw_version.as_deref()
}
pub fn label(&self) -> &'static str {
self.version.label()
}
/// Parse a raw broker version string (e.g. `3.1.2`, `3.0.7`, or
/// `Pulsar version 3.1.2 ...`) into a known [`PulsarVersion`].
pub fn parse_version(raw: &str) -> Option<PulsarVersion> {
let trimmed = raw.trim();
// Find the first dotted numeric token.
let token = trimmed.split_whitespace().find(|part| part.chars().next().is_some_and(|c| c.is_ascii_digit()));
let token = token.unwrap_or(trimmed);
let mut nums = token.split('.');
let major: u32 = nums.next()?.trim().parse().ok()?;
let _minor: u32 = nums.next().and_then(|m| m.trim().parse().ok()).unwrap_or(0);
// 3.x (and the 3.0/3.1 LTS family) map onto the V3_1 baseline.
match major {
3 => Some(PulsarVersion::V3_1),
// Newer majors are not yet first-party; caller decides fallback.
_ => None,
}
}
/// Capability flags for this version. Older versions missing endpoints would
/// turn the corresponding flag off here so the UI hides them.
pub fn capabilities(&self) -> MqCapabilities {
match self.version {
PulsarVersion::V3_1 => MqCapabilities {
supports_tenants: true,
supports_namespaces: true,
supports_partitioned_topics: true,
supports_subscriptions: true,
supports_create_subscription: true,
supports_reset_cursor: true,
supports_skip_messages: true,
supports_clear_backlog: true,
supports_peek_messages: true,
supports_expire_messages: true,
supports_rate_limits: true,
supports_backlog_quota: true,
supports_retention: true,
supports_permissions: true,
supports_geo_replication: true,
// Token signing is NOT a broker REST capability — see chapter 11.
supports_token_management: false,
supports_raw_admin_api: true,
},
}
}
// ---- Path construction (all relative to the admin base) ----
pub fn broker_version_path(&self) -> String {
"/admin/v2/brokers/version".to_string()
}
pub fn clusters_path(&self) -> String {
"/admin/v2/clusters".to_string()
}
pub fn tenants_path(&self) -> String {
"/admin/v2/tenants".to_string()
}
pub fn tenant_path(&self, tenant: &str) -> String {
format!("/admin/v2/tenants/{}", encode_segment(tenant))
}
pub fn namespaces_path(&self, tenant: &str) -> String {
format!("/admin/v2/namespaces/{}", encode_segment(tenant))
}
pub fn namespace_path(&self, tenant: &str, namespace: &str) -> String {
format!("/admin/v2/namespaces/{}/{}", encode_segment(tenant), encode_segment(namespace))
}
pub fn namespace_policies_path(&self, tenant: &str, namespace: &str) -> String {
// GET on the namespace itself returns its policies.
self.namespace_path(tenant, namespace)
}
pub fn topics_path(&self, tenant: &str, namespace: &str, persistent: bool) -> String {
let domain = if persistent { "persistent" } else { "non-persistent" };
format!("/admin/v2/{domain}/{}/{}", encode_segment(tenant), encode_segment(namespace))
}
pub fn partitioned_topics_path(&self, tenant: &str, namespace: &str, persistent: bool) -> String {
let domain = if persistent { "persistent" } else { "non-persistent" };
format!("/admin/v2/{domain}/{}/{}/partitioned", encode_segment(tenant), encode_segment(namespace))
}
/// Base path for a single topic: `/admin/v2/{domain}/{tenant}/{ns}/{topic}`.
pub fn topic_base_path(&self, domain: &str, topic_path: &str) -> String {
format!("/admin/v2/{domain}/{}", encode_topic_path(topic_path))
}
pub fn topic_partitions_path(&self, domain: &str, topic_path: &str) -> String {
format!("/admin/v2/{domain}/{}/partitions", encode_topic_path(topic_path))
}
pub fn topic_stats_path(&self, domain: &str, topic_path: &str) -> String {
format!("/admin/v2/{domain}/{}/stats", encode_topic_path(topic_path))
}
pub fn topic_partitioned_stats_path(&self, domain: &str, topic_path: &str, per_partition: bool) -> String {
let path = format!("/admin/v2/{domain}/{}/partitioned-stats", encode_topic_path(topic_path));
if per_partition {
format!("{path}?perPartition=true")
} else {
path
}
}
pub fn topic_internal_stats_path(&self, domain: &str, topic_path: &str) -> String {
format!("/admin/v2/{domain}/{}/internalStats", encode_topic_path(topic_path))
}
pub fn subscriptions_path(&self, domain: &str, topic_path: &str) -> String {
format!("/admin/v2/{domain}/{}/subscriptions", encode_topic_path(topic_path))
}
pub fn subscription_path(&self, domain: &str, topic_path: &str, sub: &str) -> String {
format!("/admin/v2/{domain}/{}/subscription/{}", encode_topic_path(topic_path), encode_segment(sub))
}
pub fn subscription_reset_cursor_path(&self, domain: &str, topic_path: &str, sub: &str) -> String {
format!("/admin/v2/{domain}/{}/subscription/{}/resetcursor", encode_topic_path(topic_path), encode_segment(sub))
}
pub fn subscription_skip_path(&self, domain: &str, topic_path: &str, sub: &str, count: u32) -> String {
format!(
"/admin/v2/{domain}/{}/subscription/{}/skip/{count}",
encode_topic_path(topic_path),
encode_segment(sub)
)
}
pub fn subscription_skip_all_path(&self, domain: &str, topic_path: &str, sub: &str) -> String {
format!("/admin/v2/{domain}/{}/subscription/{}/skip_all", encode_topic_path(topic_path), encode_segment(sub))
}
pub fn subscription_peek_path(&self, domain: &str, topic_path: &str, sub: &str, position: u32) -> String {
format!(
"/admin/v2/{domain}/{}/subscription/{}/position/{position}",
encode_topic_path(topic_path),
encode_segment(sub)
)
}
pub fn subscription_expire_path(&self, domain: &str, topic_path: &str, sub: &str, expire_seconds: i64) -> String {
format!(
"/admin/v2/{domain}/{}/subscription/{}/expireMessages/{expire_seconds}",
encode_topic_path(topic_path),
encode_segment(sub)
)
}
pub fn topic_permissions_path(&self, domain: &str, topic_path: &str) -> String {
format!("/admin/v2/{domain}/{}/permissions", encode_topic_path(topic_path))
}
pub fn topic_permission_role_path(&self, domain: &str, topic_path: &str, role: &str) -> String {
format!("/admin/v2/{domain}/{}/permissions/{}", encode_topic_path(topic_path), encode_segment(role))
}
pub fn namespace_permissions_path(&self, tenant: &str, namespace: &str) -> String {
format!("/admin/v2/namespaces/{}/{}/permissions", encode_segment(tenant), encode_segment(namespace))
}
pub fn namespace_permission_role_path(&self, tenant: &str, namespace: &str, role: &str) -> String {
format!(
"/admin/v2/namespaces/{}/{}/permissions/{}",
encode_segment(tenant),
encode_segment(namespace),
encode_segment(role)
)
}
pub fn namespace_publish_rate_path(&self, tenant: &str, namespace: &str) -> String {
format!("/admin/v2/namespaces/{}/{}/publishRate", encode_segment(tenant), encode_segment(namespace))
}
pub fn namespace_dispatch_rate_path(&self, tenant: &str, namespace: &str) -> String {
format!("/admin/v2/namespaces/{}/{}/dispatchRate", encode_segment(tenant), encode_segment(namespace))
}
pub fn namespace_subscribe_rate_path(&self, tenant: &str, namespace: &str) -> String {
format!("/admin/v2/namespaces/{}/{}/subscribeRate", encode_segment(tenant), encode_segment(namespace))
}
pub fn namespace_backlog_quota_path(&self, tenant: &str, namespace: &str) -> String {
format!("/admin/v2/namespaces/{}/{}/backlogQuota", encode_segment(tenant), encode_segment(namespace))
}
pub fn namespace_retention_path(&self, tenant: &str, namespace: &str) -> String {
format!("/admin/v2/namespaces/{}/{}/retention", encode_segment(tenant), encode_segment(namespace))
}
pub fn topic_publish_rate_path(&self, domain: &str, topic_path: &str) -> String {
format!("/admin/v2/{domain}/{}/publishRate", encode_topic_path(topic_path))
}
pub fn topic_dispatch_rate_path(&self, domain: &str, topic_path: &str) -> String {
format!("/admin/v2/{domain}/{}/dispatchRate", encode_topic_path(topic_path))
}
pub fn topic_backlog_quota_path(&self, domain: &str, topic_path: &str) -> String {
format!("/admin/v2/{domain}/{}/backlogQuota", encode_topic_path(topic_path))
}
pub fn topic_retention_path(&self, domain: &str, topic_path: &str) -> String {
format!("/admin/v2/{domain}/{}/retention", encode_topic_path(topic_path))
}
// ---- Response parsing (version-tolerant) ----
/// Parse the aggregated topic stats payload. Field names are read
/// defensively so minor cross-version renames don't break parsing.
pub fn parse_topic_stats(&self, raw: &serde_json::Value) -> TopicStats {
let f64_of = |key: &str| raw.get(key).and_then(serde_json::Value::as_f64).unwrap_or(0.0);
let i64_of = |key: &str| raw.get(key).and_then(serde_json::Value::as_i64).unwrap_or(0);
// Backlog appears as `backlogSize` (bytes) in newer stats; some payloads
// expose `msgBacklog` per subscription only. Read both tolerantly.
let backlog_size = raw
.get("backlogSize")
.and_then(serde_json::Value::as_i64)
.or_else(|| raw.get("backlog").and_then(serde_json::Value::as_i64))
.unwrap_or(0);
let subscription_count =
raw.get("subscriptions").and_then(serde_json::Value::as_object).map(|m| m.len() as u32).unwrap_or(0);
let producer_count = raw
.get("publishers")
.and_then(serde_json::Value::as_array)
.map(|a| a.len() as u32)
.or_else(|| raw.get("producerCount").and_then(serde_json::Value::as_u64).map(|v| v as u32))
.unwrap_or(0);
TopicStats {
msg_rate_in: f64_of("msgRateIn"),
msg_rate_out: f64_of("msgRateOut"),
msg_throughput_in: f64_of("msgThroughputIn"),
msg_throughput_out: f64_of("msgThroughputOut"),
storage_size: i64_of("storageSize"),
backlog_size,
msg_in_counter: i64_of("msgInCounter"),
msg_out_counter: i64_of("msgOutCounter"),
subscription_count,
producer_count,
raw: raw.clone(),
}
}
/// Parse the `subscriptions` map from a topic stats payload into a typed list.
pub fn parse_subscriptions(&self, stats_raw: &serde_json::Value) -> Vec<SubscriptionInfo> {
let mut by_name = std::collections::BTreeMap::new();
let has_root_subscriptions = append_subscriptions_from_stats(stats_raw, &mut by_name, false);
if let Some(partitions) = stats_raw.get("partitions").and_then(serde_json::Value::as_object) {
for partition in partitions.values() {
append_subscriptions_from_stats(partition, &mut by_name, has_root_subscriptions);
}
}
by_name.into_values().collect()
}
}
fn append_subscriptions_from_stats(
stats_raw: &serde_json::Value,
by_name: &mut std::collections::BTreeMap<String, SubscriptionInfo>,
keep_existing_metrics: bool,
) -> bool {
let Some(subs) = stats_raw.get("subscriptions").and_then(serde_json::Value::as_object) else {
return false;
};
for (name, body) in subs {
merge_subscription(by_name, subscription_from_stats(name, body), keep_existing_metrics);
}
!subs.is_empty()
}
fn merge_subscription(
by_name: &mut std::collections::BTreeMap<String, SubscriptionInfo>,
mut incoming: SubscriptionInfo,
keep_existing_metrics: bool,
) {
let Some(existing) = by_name.get_mut(&incoming.name) else {
by_name.insert(incoming.name.clone(), incoming);
return;
};
if existing.sub_type.is_empty() && !incoming.sub_type.is_empty() {
existing.sub_type = incoming.sub_type;
}
if keep_existing_metrics {
existing.msg_backlog = existing.msg_backlog.max(incoming.msg_backlog);
existing.msg_rate_out = existing.msg_rate_out.max(incoming.msg_rate_out);
existing.msg_throughput_out = existing.msg_throughput_out.max(incoming.msg_throughput_out);
} else {
existing.msg_backlog = existing.msg_backlog.saturating_add(incoming.msg_backlog);
existing.msg_rate_out += incoming.msg_rate_out;
existing.msg_throughput_out += incoming.msg_throughput_out;
}
existing.consumers.append(&mut incoming.consumers);
}
fn subscription_from_stats(name: &str, body: &serde_json::Value) -> SubscriptionInfo {
let consumers = body
.get("consumers")
.and_then(serde_json::Value::as_array)
.map(|arr| arr.iter().map(parse_consumer).collect())
.unwrap_or_default();
SubscriptionInfo {
name: name.to_string(),
sub_type: body.get("type").and_then(serde_json::Value::as_str).unwrap_or("").to_string(),
msg_backlog: body.get("msgBacklog").and_then(serde_json::Value::as_i64).unwrap_or(0),
msg_rate_out: body.get("msgRateOut").and_then(serde_json::Value::as_f64).unwrap_or(0.0),
msg_throughput_out: body.get("msgThroughputOut").and_then(serde_json::Value::as_f64).unwrap_or(0.0),
consumers,
}
}
fn parse_consumer(v: &serde_json::Value) -> super::super::types::ConsumerInfo {
super::super::types::ConsumerInfo {
consumer_name: v.get("consumerName").and_then(serde_json::Value::as_str).unwrap_or("").to_string(),
msg_rate_out: v.get("msgRateOut").and_then(serde_json::Value::as_f64).unwrap_or(0.0),
msg_throughput_out: v.get("msgThroughputOut").and_then(serde_json::Value::as_f64).unwrap_or(0.0),
available_permits: v.get("availablePermits").and_then(serde_json::Value::as_i64).unwrap_or(0),
address: v.get("address").and_then(serde_json::Value::as_str).unwrap_or("").to_string(),
client_version: v.get("clientVersion").and_then(serde_json::Value::as_str).unwrap_or("").to_string(),
}
}
fn encode_segment(segment: &str) -> String {
utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string()
}
fn encode_topic_path(topic_path: &str) -> String {
let mut parts = topic_path.splitn(3, '/');
let tenant = parts.next();
let namespace = parts.next();
let topic = parts.next();
match (tenant, namespace, topic) {
(Some(tenant), Some(namespace), Some(topic)) => {
format!("{}/{}/{}", encode_segment(tenant), encode_segment(namespace), encode_segment(topic))
}
_ => topic_path.split('/').map(encode_segment).collect::<Vec<_>>().join("/"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_plain_version_strings() {
assert_eq!(PulsarApiProfile::parse_version("3.1.2"), Some(PulsarVersion::V3_1));
assert_eq!(PulsarApiProfile::parse_version("3.0.7"), Some(PulsarVersion::V3_1));
assert_eq!(PulsarApiProfile::parse_version(" 3.1.0 "), Some(PulsarVersion::V3_1));
}
#[test]
fn parses_verbose_version_strings() {
assert_eq!(PulsarApiProfile::parse_version("Pulsar version 3.1.2 (rev abc)"), Some(PulsarVersion::V3_1));
}
#[test]
fn unknown_major_returns_none() {
assert_eq!(PulsarApiProfile::parse_version("4.0.1"), None);
assert_eq!(PulsarApiProfile::parse_version("not-a-version"), None);
}
#[test]
fn baseline_profile_is_fallback_3_1() {
let p = PulsarApiProfile::default_baseline();
assert_eq!(p.version(), PulsarVersion::V3_1);
assert_eq!(p.detection(), VersionDetection::Fallback);
assert!(p.capabilities().supports_tenants);
assert!(!p.capabilities().supports_token_management);
}
#[test]
fn parses_topic_stats_fields() {
let raw = serde_json::json!({
"msgRateIn": 12.5,
"msgRateOut": 7.0,
"storageSize": 4096,
"backlogSize": 128,
"publishers": [{"producerName": "p1"}],
"subscriptions": {
"sub-a": {"msgBacklog": 10, "msgRateOut": 1.0, "type": "Shared", "consumers": []},
"sub-b": {"msgBacklog": 0, "type": "Exclusive", "consumers": []}
}
});
let profile = PulsarApiProfile::default_baseline();
let stats = profile.parse_topic_stats(&raw);
assert_eq!(stats.msg_rate_in, 12.5);
assert_eq!(stats.storage_size, 4096);
assert_eq!(stats.backlog_size, 128);
assert_eq!(stats.subscription_count, 2);
assert_eq!(stats.producer_count, 1);
let subs = profile.parse_subscriptions(&raw);
assert_eq!(subs.len(), 2);
}
#[test]
fn parses_partitioned_stats_subscriptions() {
let raw = serde_json::json!({
"partitions": {
"persistent://public/default/orders-partition-0": {
"subscriptions": {
"sub-a": {
"msgBacklog": 3,
"msgRateOut": 1.5,
"msgThroughputOut": 128.0,
"type": "Shared",
"consumers": [{ "consumerName": "c0", "address": "/127.0.0.1:1000" }]
}
}
},
"persistent://public/default/orders-partition-1": {
"subscriptions": {
"sub-a": {
"msgBacklog": 5,
"msgRateOut": 2.0,
"msgThroughputOut": 256.0,
"type": "Shared",
"consumers": [{ "consumerName": "c1", "address": "/127.0.0.1:1001" }]
}
}
}
}
});
let profile = PulsarApiProfile::default_baseline();
let subs = profile.parse_subscriptions(&raw);
assert_eq!(subs.len(), 1);
assert_eq!(subs[0].name, "sub-a");
assert_eq!(subs[0].sub_type, "Shared");
assert_eq!(subs[0].msg_backlog, 8);
assert_eq!(subs[0].msg_rate_out, 3.5);
assert_eq!(subs[0].msg_throughput_out, 384.0);
assert_eq!(subs[0].consumers.len(), 2);
}
#[test]
fn encodes_each_admin_path_segment() {
let profile = PulsarApiProfile::default_baseline();
assert_eq!(profile.tenant_path("tenant with/slash?x"), "/admin/v2/tenants/tenant%20with%2Fslash%3Fx");
assert_eq!(profile.namespace_path("public", "default space"), "/admin/v2/namespaces/public/default%20space");
assert_eq!(
profile.topic_base_path("persistent", "public/default/orders/a?b"),
"/admin/v2/persistent/public/default/orders%2Fa%3Fb"
);
assert_eq!(
profile.subscription_path("persistent", "public/default/orders/a?b", "sub/role #1"),
"/admin/v2/persistent/public/default/orders%2Fa%3Fb/subscription/sub%2Frole%20%231"
);
assert_eq!(
profile.topic_permission_role_path("persistent", "public/default/orders", "role/a?b"),
"/admin/v2/persistent/public/default/orders/permissions/role%2Fa%3Fb"
);
}
#[test]
fn partitioned_stats_path_encodes_topic_and_requests_partition_detail() {
let profile = PulsarApiProfile::default_baseline();
assert_eq!(
profile.topic_partitioned_stats_path("persistent", "b2b/ec-product-service/goods_status_change", true),
"/admin/v2/persistent/b2b/ec%2Dproduct%2Dservice/goods%5Fstatus%5Fchange/partitioned-stats?perPartition=true"
);
assert_eq!(
profile.topic_partitioned_stats_path("persistent", "public/default/orders/a?b", true),
"/admin/v2/persistent/public/default/orders%2Fa%3Fb/partitioned-stats?perPartition=true"
);
}
#[test]
fn peek_path_encodes_topic_and_subscription_segments() {
let profile = PulsarApiProfile::default_baseline();
assert_eq!(
profile.subscription_peek_path("persistent", "public/default/orders/a?b", "sub/role #1", 3),
"/admin/v2/persistent/public/default/orders%2Fa%3Fb/subscription/sub%2Frole%20%231/position/3"
);
}
}
+259
View File
@@ -0,0 +1,259 @@
//! Authentication for message queue admin connections. Injects credentials
//! into outgoing `reqwest` requests, with token caching for OAuth2
//! client-credentials so we don't exchange a token on every call.
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use super::util::truncate;
/// Authentication method for an MQ admin connection.
///
/// Sensitive fields (token / password / secret) are stored through the
/// project's encrypted connection-secrets / keychain path and are never logged.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum MqAuth {
None,
/// Pulsar JWT bearer token.
Token {
token: String,
},
/// HTTP Basic auth.
Basic {
username: String,
password: String,
},
/// Arbitrary API key header, e.g. `Authorization: <value>` or a custom header.
ApiKey {
header: String,
value: String,
},
/// OAuth2 client-credentials flow (Pulsar's `oauth2` auth plugin).
OAuth2 {
issuer_url: String,
client_id: String,
client_secret: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
audience: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
scope: Option<String>,
},
}
impl Default for MqAuth {
fn default() -> Self {
MqAuth::None
}
}
/// Caches an OAuth2 access token with its expiry so repeated requests reuse it.
/// Also tracks in-flight token requests to prevent concurrent fetches.
#[derive(Debug, Default, Clone)]
pub struct TokenCache {
inner: Arc<Mutex<TokenCacheState>>,
}
#[derive(Debug, Default)]
struct TokenCacheState {
cached: Option<CachedToken>,
/// Tracks whether a token fetch is currently in progress. Subsequent requests
/// will wait on this shared future instead of issuing duplicate OAuth2 requests.
#[allow(clippy::type_complexity)]
in_flight: Option<Arc<tokio::sync::Notify>>,
}
#[derive(Debug, Clone)]
struct CachedToken {
token: String,
expires_at: Instant,
}
/// Refresh the cached token slightly before it actually expires.
const TOKEN_EXPIRY_SKEW: Duration = Duration::from_secs(30);
#[derive(Deserialize)]
struct OAuth2TokenResponse {
access_token: String,
#[serde(default)]
expires_in: Option<u64>,
}
impl MqAuth {
/// Apply this auth method to a request builder, fetching/refreshing an
/// OAuth2 token through `http`/`cache` if necessary.
pub async fn apply(
&self,
req: reqwest::RequestBuilder,
http: &reqwest::Client,
cache: &TokenCache,
) -> Result<reqwest::RequestBuilder, String> {
match self {
MqAuth::None => Ok(req),
MqAuth::Token { token } => Ok(req.bearer_auth(token)),
MqAuth::Basic { username, password } => Ok(req.basic_auth(username, Some(password))),
MqAuth::ApiKey { header, value } => Ok(req.header(header.as_str(), value.as_str())),
MqAuth::OAuth2 { .. } => {
let token = self.fetch_oauth2_token(http, cache).await?;
Ok(req.bearer_auth(token))
}
}
}
async fn fetch_oauth2_token(&self, http: &reqwest::Client, cache: &TokenCache) -> Result<String, String> {
let MqAuth::OAuth2 { issuer_url, client_id, client_secret, audience, scope } = self else {
return Err("not an OAuth2 auth method".to_string());
};
// Fast path: reuse a still-valid cached token.
let notify = {
let mut guard = cache.inner.lock().await;
if let Some(cached) = &guard.cached {
if cached.expires_at > Instant::now() {
return Ok(cached.token.clone());
}
}
// Check if another task is already fetching a token
if let Some(existing_notify) = &guard.in_flight {
let notify = existing_notify.clone();
drop(guard);
// Wait for the in-flight request to complete
notify.notified().await;
// Re-check the cache after waiting
let guard = cache.inner.lock().await;
if let Some(cached) = &guard.cached {
if cached.expires_at > Instant::now() {
return Ok(cached.token.clone());
}
}
// Token still expired after waiting, we'll fetch ourselves
return Err("Token fetch by another task failed or token still expired".to_string());
}
// Mark that we're now fetching
let notify = Arc::new(tokio::sync::Notify::new());
guard.in_flight = Some(notify.clone());
notify
};
// Fetch the token (without holding the lock)
let result = self.do_oauth2_token_fetch(http, issuer_url, client_id, client_secret, audience, scope).await;
// Update cache and clear in-flight flag
{
let mut guard = cache.inner.lock().await;
guard.in_flight = None;
match result {
Ok((token, ttl)) => {
let expires_at = Instant::now() + Duration::from_secs(ttl).saturating_sub(TOKEN_EXPIRY_SKEW);
guard.cached = Some(CachedToken { token: token.clone(), expires_at });
notify.notify_waiters();
Ok(token)
}
Err(e) => {
notify.notify_waiters();
Err(e)
}
}
}
}
async fn do_oauth2_token_fetch(
&self,
http: &reqwest::Client,
issuer_url: &str,
client_id: &str,
client_secret: &str,
audience: &Option<String>,
scope: &Option<String>,
) -> Result<(String, u64), String> {
let mut form = vec![
("grant_type", "client_credentials".to_string()),
("client_id", client_id.to_string()),
("client_secret", client_secret.to_string()),
];
if let Some(audience) = audience {
form.push(("audience", audience.clone()));
}
if let Some(scope) = scope {
form.push(("scope", scope.clone()));
}
let resp =
http.post(issuer_url).form(&form).send().await.map_err(|e| format!("OAuth2 token request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
let detail = oauth2_error_detail(&body).unwrap_or_else(|| truncate(&body, 300));
return Err(format!("OAuth2 token endpoint returned {status}: {detail}"));
}
let parsed: OAuth2TokenResponse =
resp.json().await.map_err(|e| format!("Failed to parse OAuth2 token response: {e}"))?;
let ttl = parsed.expires_in.unwrap_or(3600);
Ok((parsed.access_token, ttl))
}
}
/// Extract a human-readable OAuth2 error detail from a JSON error response.
/// Returns `None` when the body is not a valid OAuth2 error payload.
fn oauth2_error_detail(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
let error = value.get("error")?.as_str()?;
let description = value.get("error_description").and_then(serde_json::Value::as_str);
Some(match description {
Some(desc) => format!("{error}: {desc}"),
None => error.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_default_is_none() {
assert!(matches!(MqAuth::default(), MqAuth::None));
}
#[test]
fn token_auth_serde_roundtrip() {
let auth = MqAuth::Token { token: "abc".to_string() };
let json = serde_json::to_string(&auth).expect("should serialize MqAuth");
assert!(json.contains("\"kind\":\"token\""));
let back: MqAuth = serde_json::from_str(&json).expect("should deserialize MqAuth");
assert!(matches!(back, MqAuth::Token { token } if token == "abc"));
}
#[test]
fn oauth2_optional_fields_skipped_when_absent() {
let auth = MqAuth::OAuth2 {
issuer_url: "https://issuer/token".to_string(),
client_id: "id".to_string(),
client_secret: "secret".to_string(),
audience: None,
scope: None,
};
let json = serde_json::to_string(&auth).expect("should serialize OAuth2 auth");
assert!(!json.contains("audience"));
assert!(!json.contains("scope"));
}
#[test]
fn oauth2_error_detail_parses_standard_error() {
let body = r#"{"error":"invalid_client","error_description":"Client authentication failed"}"#;
assert_eq!(oauth2_error_detail(body).as_deref(), Some("invalid_client: Client authentication failed"));
}
#[test]
fn oauth2_error_detail_returns_none_for_non_json() {
assert!(oauth2_error_detail("not json").is_none());
}
}
+183
View File
@@ -0,0 +1,183 @@
//! Parses the message queue admin configuration out of a `ConnectionConfig`.
//!
//! MQ admin connections reuse the generic `external_config` extension slot on
//! `ConnectionConfig` rather than adding top-level fields, keeping the 50+
//! database-type connection model untouched.
use serde::{Deserialize, Serialize};
use crate::models::connection::ConnectionConfig;
use crate::mq::auth::MqAuth;
use crate::mq::types::{MqSystemKind, MqTokenSigningConfig};
/// Runtime TCP endpoint override for MQ admin requests.
///
/// The public admin URL remains unchanged so TLS hostname verification, SNI and
/// the HTTP Host header continue to target the broker name. The HTTP client uses
/// this endpoint only for the underlying TCP connection, e.g. after an SSH/proxy
/// tunnel has mapped the broker to a local port.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqConnectOverride {
pub host: String,
pub port: u16,
}
/// Configuration for an MQ admin connection, decoded from
/// `ConnectionConfig.external_config`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqAdminConfig {
pub system_kind: MqSystemKind,
/// Admin REST base URL, e.g. `http://broker:8080`.
pub admin_url: String,
#[serde(default)]
pub auth: MqAuth,
/// Skip TLS certificate verification (self-signed clusters only).
#[serde(default)]
pub tls_skip_verify: bool,
/// Manually pin a server version (e.g. `3.1`), skipping auto-detection for
/// environments where the version endpoint is blocked by a gateway.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pinned_version: Option<String>,
/// Optional local JWT signing configuration. This is used only by dbx to
/// issue Pulsar client tokens; the signing key itself is stored through the
/// connection secret path, not in plain connection JSON.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_signing: Option<MqTokenSigningConfig>,
/// Runtime-only TCP endpoint override used by transport layers.
#[serde(skip)]
pub connect_override: Option<MqConnectOverride>,
/// System-specific extension fields (e.g. Kafka bootstrap servers).
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub extra: serde_json::Value,
}
impl MqAdminConfig {
/// Decode the MQ admin config from a connection's `external_config` slot.
pub fn from_connection(cfg: &ConnectionConfig) -> Result<Self, String> {
let raw = cfg
.external_config
.as_ref()
.ok_or("This connection has no message queue admin configuration (external_config is empty)")?;
let mut parsed: MqAdminConfig = serde_json::from_value(raw.clone())
.map_err(|e| format!("Failed to parse message queue admin config: {e}"))?;
parsed.admin_url = parsed.admin_url.trim().to_string();
if parsed.admin_url.is_empty() {
return Err("Message queue admin URL is empty".to_string());
}
Ok(parsed)
}
pub fn token_signing_configured(&self) -> bool {
self.token_signing.as_ref().is_some_and(MqTokenSigningConfig::is_configured)
}
pub fn with_admin_endpoint(mut self, host: &str, port: u16) -> Result<Self, String> {
self.admin_url = admin_url_with_endpoint(&self.admin_url, host, port)?;
Ok(self)
}
pub fn with_connect_override(mut self, host: &str, port: u16) -> Self {
self.connect_override = Some(MqConnectOverride { host: host.to_string(), port });
self
}
}
pub fn admin_url_with_endpoint(admin_url: &str, host: &str, port: u16) -> Result<String, String> {
let mut url = reqwest::Url::parse(admin_url).map_err(|e| format!("MQ Admin URL is invalid: {e}"))?;
url.set_host(Some(host)).map_err(|_| format!("MQ Admin URL host is invalid: {host}"))?;
url.set_port(Some(port)).map_err(|_| format!("MQ Admin URL port is invalid: {port}"))?;
Ok(url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn connection_with_external(value: serde_json::Value) -> ConnectionConfig {
let mut cfg = ConnectionConfig {
id: "c1".to_string(),
name: "mq".to_string(),
db_type: crate::models::connection::DatabaseType::MessageQueue,
driver_profile: None,
driver_label: None,
url_params: None,
host: String::new(),
port: 0,
username: String::new(),
password: String::new(),
database: None,
visible_databases: None,
attached_databases: Vec::new(),
color: None,
transport_layers: Vec::new(),
connect_timeout_secs: 5,
query_timeout_secs: 30,
idle_timeout_secs: 60,
keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(),
ssl: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
sysdba: false,
oracle_connection_type: None,
connection_string: None,
redis_connection_mode: None,
redis_sentinel_master: String::new(),
redis_sentinel_nodes: String::new(),
redis_sentinel_username: String::new(),
redis_sentinel_password: String::new(),
redis_sentinel_tls: false,
redis_cluster_nodes: String::new(),
redis_key_separator: String::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
external_config: Some(value),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
};
cfg.redis_key_separator = ":".to_string();
cfg
}
#[test]
fn parses_pulsar_config() {
let cfg = connection_with_external(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://localhost:8080",
"auth": { "kind": "none" }
}));
let mqc = MqAdminConfig::from_connection(&cfg).expect("should parse valid Pulsar config");
assert_eq!(mqc.system_kind, MqSystemKind::Pulsar);
assert_eq!(mqc.admin_url, "http://localhost:8080");
assert!(matches!(mqc.auth, MqAuth::None));
}
#[test]
fn errors_when_external_config_missing() {
let mut cfg = connection_with_external(serde_json::Value::Null);
cfg.external_config = None;
assert!(MqAdminConfig::from_connection(&cfg).is_err());
}
#[test]
fn errors_on_empty_admin_url() {
let cfg = connection_with_external(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": " "
}));
assert!(MqAdminConfig::from_connection(&cfg).is_err());
}
#[test]
fn admin_url_with_endpoint_preserves_scheme_path_and_query() {
let rewritten =
admin_url_with_endpoint("https://broker.internal:8443/pulsar-admin?tenant=public", "127.0.0.1", 49152)
.expect("should rewrite admin URL with endpoint");
assert_eq!(rewritten, "https://127.0.0.1:49152/pulsar-admin?tenant=public");
}
}
+116
View File
@@ -0,0 +1,116 @@
//! Message queue admin console support.
//!
//! Provides a pluggable, port-adapter abstraction for managing message queue
//! systems (Apache Pulsar today; Kafka / RocketMQ are reserved). The module is
//! gated behind the `mq-admin` Cargo feature so builds that don't need it pay
//! nothing.
//!
//! Architecture mirrors the existing `agent_kv` pattern: business logic lives in
//! `service::*_core` functions shared by the desktop command layer and the web
//! route layer; this module owns the trait, the typed model, and the registry
//! that caches one adapter per connection.
pub mod adapters;
pub mod auth;
pub mod config;
pub mod port;
pub mod service;
pub mod token;
pub mod types;
pub(crate) mod util;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use crate::models::connection::ConnectionConfig;
use crate::mq::adapters::pulsar::PulsarAdmin;
use crate::mq::config::MqAdminConfig;
use crate::mq::port::MessageQueueAdmin;
use crate::mq::types::MqSystemKind as MqSystemKindInternal;
pub use crate::mq::auth::MqAuth;
pub use crate::mq::config::MqAdminConfig as MqConfig;
pub use crate::mq::types::*;
/// Caches one live admin adapter per connection id. Adapters are built lazily on
/// first use and dropped when the connection is closed.
///
/// Each connection has its own build-lock so concurrent first-use requests for
/// the same connection block until the first builder finishes, rather than both
/// racing to construct an adapter.
#[derive(Default)]
pub struct MqAdminRegistry {
instances: RwLock<HashMap<String, Arc<dyn MessageQueueAdmin>>>,
build_locks: RwLock<HashMap<String, Arc<Mutex<()>>>>,
}
impl MqAdminRegistry {
pub fn new() -> Self {
Self { instances: RwLock::new(HashMap::new()), build_locks: RwLock::new(HashMap::new()) }
}
/// Return the cached adapter for this connection, building it from the
/// connection's `external_config` if not already present.
pub async fn get_or_build(&self, cfg: &ConnectionConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
let mqc = MqAdminConfig::from_connection(cfg)?;
self.get_or_build_config(&cfg.id, mqc).await
}
pub async fn get_or_build_config(
&self,
connection_id: &str,
mqc: MqAdminConfig,
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
// Fast path: return the cached adapter.
if let Some(adapter) = self.instances.read().await.get(connection_id) {
return Ok(adapter.clone());
}
// Slow path: acquire a per-connection build lock so only one task
// constructs the adapter at a time.
let lock = {
let mut locks = self.build_locks.write().await;
locks.entry(connection_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone()
};
let _guard = lock.lock().await;
// Another task may have built it while we were waiting for the lock.
if let Some(adapter) = self.instances.read().await.get(connection_id) {
return Ok(adapter.clone());
}
let adapter = build_adapter(mqc).await?;
self.instances.write().await.insert(connection_id.to_string(), adapter.clone());
Ok(adapter)
}
/// Drop the cached adapter for a connection (called on disconnect).
pub async fn drop_connection(&self, connection_id: &str) {
self.instances.write().await.remove(connection_id);
self.build_locks.write().await.remove(connection_id);
}
/// Build a fresh adapter without caching it — used for connection tests
/// where we don't want to retain state.
pub async fn build_transient(&self, cfg: &ConnectionConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
let mqc = MqAdminConfig::from_connection(cfg)?;
self.build_transient_config(mqc).await
}
pub async fn build_transient_config(&self, mqc: MqAdminConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
build_adapter(mqc).await
}
}
async fn build_adapter(mqc: MqAdminConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
match mqc.system_kind {
MqSystemKindInternal::Pulsar => {
let adapter = PulsarAdmin::new(mqc).await?;
Ok(Arc::new(adapter))
}
MqSystemKindInternal::Kafka => Err("Kafka admin is not yet implemented".to_string()),
MqSystemKindInternal::RocketMq => Err("RocketMQ admin is not yet implemented".to_string()),
}
}
+77
View File
@@ -0,0 +1,77 @@
//! The message queue admin port — the abstraction all MQ systems implement.
//!
//! Commands and routes program against this trait only; concrete systems
//! (Pulsar today, Kafka / RocketMQ later) live in `adapters/`.
use async_trait::async_trait;
use crate::mq::types::*;
/// Unified management interface for message queue systems.
#[async_trait]
pub trait MessageQueueAdmin: Send + Sync {
/// Capability flags so the frontend can show/hide functionality.
fn capabilities(&self) -> MqCapabilities;
fn system_kind(&self) -> MqSystemKind;
/// Connectivity test; returns cluster/version info.
async fn test_connection(&self) -> Result<MqClusterInfo, String>;
// ---- Tenants ----
async fn list_tenants(&self) -> Result<Vec<TenantInfo>, String>;
async fn get_tenant(&self, name: &str) -> Result<TenantInfo, String>;
async fn create_tenant(&self, name: &str, cfg: TenantConfig) -> Result<(), String>;
async fn update_tenant(&self, name: &str, cfg: TenantConfig) -> Result<(), String>;
async fn delete_tenant(&self, name: &str, force: bool) -> Result<(), String>;
// ---- Namespaces ----
async fn list_namespaces(&self, tenant: &str) -> Result<Vec<NamespaceInfo>, String>;
async fn create_namespace(&self, ns: &NamespaceRef, cfg: NamespaceConfig) -> Result<(), String>;
async fn delete_namespace(&self, ns: &NamespaceRef, force: bool) -> Result<(), String>;
async fn get_namespace_policies(&self, ns: &NamespaceRef) -> Result<serde_json::Value, String>;
// ---- Topics ----
async fn list_topics(&self, ns: &NamespaceRef, opts: ListTopicsOpts) -> Result<Vec<TopicInfo>, String>;
async fn create_topic(&self, topic: &TopicRef, partitions: Option<u32>) -> Result<(), String>;
async fn delete_topic(&self, topic: &TopicRef, force: bool) -> Result<(), String>;
async fn update_partitions(&self, topic: &TopicRef, partitions: u32) -> Result<(), String>;
async fn get_topic_stats(&self, topic: &TopicRef) -> Result<TopicStats, String>;
async fn get_topic_internal_stats(&self, topic: &TopicRef) -> Result<serde_json::Value, String>;
// ---- Subscriptions ----
async fn list_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String>;
async fn create_subscription(&self, topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<(), String>;
async fn delete_subscription(&self, topic: &TopicRef, sub: &str, force: bool) -> Result<(), String>;
async fn skip_messages(&self, topic: &TopicRef, sub: &str, count: SkipCount) -> Result<(), String>;
async fn reset_cursor(&self, topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<(), String>;
async fn clear_backlog(&self, topic: &TopicRef, sub: &str) -> Result<(), String>;
async fn peek_messages(&self, topic: &TopicRef, sub: &str, count: u32) -> Result<Vec<PeekedMessage>, String>;
async fn expire_messages(&self, topic: &TopicRef, sub: &str, expire_seconds: i64) -> Result<(), String>;
// ---- Producers / consumers (runtime, read from stats) ----
async fn list_producers(&self, topic: &TopicRef) -> Result<Vec<ProducerInfo>, String>;
async fn list_consumers(&self, topic: &TopicRef, sub: &str) -> Result<Vec<ConsumerInfo>, String>;
/// Unload a topic (Pulsar has no per-connection kill; unload is the closest).
async fn unload_topic(&self, topic: &TopicRef) -> Result<(), String>;
// ---- Rate limits / quotas / retention ----
async fn set_publish_rate(&self, scope: &PolicyScope, rate: PublishRate) -> Result<(), String>;
async fn set_dispatch_rate(&self, scope: &PolicyScope, rate: DispatchRate) -> Result<(), String>;
async fn set_subscribe_rate(&self, scope: &PolicyScope, rate: SubscribeRate) -> Result<(), String>;
async fn set_backlog_quota(&self, scope: &PolicyScope, quota: BacklogQuota) -> Result<(), String>;
async fn set_retention(&self, scope: &PolicyScope, retention: RetentionPolicy) -> Result<(), String>;
async fn get_effective_policies(&self, scope: &PolicyScope) -> Result<serde_json::Value, String>;
// ---- Permissions (who can produce / consume) ----
async fn grant_permission(&self, scope: &PolicyScope, role: &str, actions: Vec<AuthAction>) -> Result<(), String>;
async fn revoke_permission(&self, scope: &PolicyScope, role: &str) -> Result<(), String>;
async fn list_permissions(&self, scope: &PolicyScope) -> Result<PermissionMap, String>;
// ---- Monitoring ----
async fn get_backlog(&self, topic: &TopicRef, sub: Option<&str>) -> Result<BacklogStats, String>;
/// Escape hatch: proxy an arbitrary admin REST call. Covers any endpoint the
/// typed methods do not.
async fn raw_request(&self, req: MqRawRequest) -> Result<MqRawResponse, String>;
}
+661
View File
@@ -0,0 +1,661 @@
//! Service-layer functions for message queue admin operations. These `*_core`
//! functions are shared by both the desktop command layer
//! (`src-tauri/src/commands/mq_cmd.rs`) and the web route layer
//! (`crates/dbx-web/src/routes/mq.rs`), keeping the business logic unified.
//!
//! Mirrors the pattern used by `agent_kv::*_core`.
use crate::connection::AppState;
use crate::mq::config::MqAdminConfig;
use crate::mq::token::sign_pulsar_token;
use crate::mq::types::*;
use chrono::{TimeZone, Utc};
use uuid::Uuid;
const MAX_PEEK_MESSAGES: u32 = 100;
/// Test connectivity to the message queue admin endpoint. Builds a transient
/// adapter (not cached) to avoid polluting the registry on failed attempts.
pub async fn mq_test_connection_core(state: &AppState, conn_id: &str) -> Result<MqClusterInfo, String> {
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?;
let adapter = state.mq_registry.build_transient_config(mqc).await?;
adapter.test_connection().await
}
// ---- Tenants ----
pub async fn mq_list_tenants_core(state: &AppState, conn_id: &str) -> Result<Vec<TenantInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_tenants().await
}
pub async fn mq_get_tenant_core(state: &AppState, conn_id: &str, name: &str) -> Result<TenantInfo, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.get_tenant(name).await
}
pub async fn mq_create_tenant_core(
state: &AppState,
conn_id: &str,
name: &str,
cfg: TenantConfig,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Create tenant").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.create_tenant(name, cfg).await
}
pub async fn mq_update_tenant_core(
state: &AppState,
conn_id: &str,
name: &str,
cfg: TenantConfig,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Update tenant").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.update_tenant(name, cfg).await
}
pub async fn mq_delete_tenant_core(state: &AppState, conn_id: &str, name: &str, force: bool) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Delete tenant").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.delete_tenant(name, force).await
}
// ---- Namespaces ----
pub async fn mq_list_namespaces_core(
state: &AppState,
conn_id: &str,
tenant: &str,
) -> Result<Vec<NamespaceInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_namespaces(tenant).await
}
pub async fn mq_create_namespace_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
cfg: NamespaceConfig,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Create namespace").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.create_namespace(&ns, cfg).await
}
pub async fn mq_delete_namespace_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
force: bool,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Delete namespace").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.delete_namespace(&ns, force).await
}
pub async fn mq_get_namespace_policies_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
) -> Result<serde_json::Value, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.get_namespace_policies(&ns).await
}
// ---- Topics ----
pub async fn mq_list_topics_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
opts: ListTopicsOpts,
) -> Result<Vec<TopicInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_topics(&ns, opts).await
}
pub async fn mq_create_topic_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
partitions: Option<u32>,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Create topic").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.create_topic(&topic, partitions).await
}
pub async fn mq_delete_topic_core(state: &AppState, conn_id: &str, topic: TopicRef, force: bool) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Delete topic").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.delete_topic(&topic, force).await
}
pub async fn mq_update_partitions_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
partitions: u32,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Update partitions").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.update_partitions(&topic, partitions).await
}
pub async fn mq_get_topic_stats_core(state: &AppState, conn_id: &str, topic: TopicRef) -> Result<TopicStats, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.get_topic_stats(&topic).await
}
pub async fn mq_get_topic_internal_stats_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
) -> Result<serde_json::Value, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.get_topic_internal_stats(&topic).await
}
// ---- Subscriptions ----
pub async fn mq_list_subscriptions_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
) -> Result<Vec<SubscriptionInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_subscriptions(&topic).await
}
pub async fn mq_create_subscription_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
sub: String,
pos: ResetPosition,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Create subscription").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.create_subscription(&topic, &sub, pos).await
}
pub async fn mq_delete_subscription_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
sub: String,
force: bool,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Delete subscription").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.delete_subscription(&topic, &sub, force).await
}
pub async fn mq_skip_messages_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
sub: String,
count: SkipCount,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Skip messages").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.skip_messages(&topic, &sub, count).await
}
pub async fn mq_reset_cursor_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
sub: String,
pos: ResetPosition,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Reset cursor").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.reset_cursor(&topic, &sub, pos).await
}
pub async fn mq_clear_backlog_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
sub: String,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Clear backlog").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.clear_backlog(&topic, &sub).await
}
pub async fn mq_peek_messages_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
sub: String,
count: u32,
) -> Result<Vec<PeekedMessage>, String> {
if count == 0 {
return Ok(Vec::new());
}
if count > MAX_PEEK_MESSAGES {
return Err(format!("Peek message count must be between 1 and {MAX_PEEK_MESSAGES}"));
}
let adapter = get_adapter(state, conn_id).await?;
adapter.peek_messages(&topic, &sub, count).await
}
pub async fn mq_expire_messages_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
sub: String,
expire_seconds: i64,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Expire messages").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.expire_messages(&topic, &sub, expire_seconds).await
}
// ---- Producers / consumers ----
pub async fn mq_list_producers_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
) -> Result<Vec<ProducerInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_producers(&topic).await
}
pub async fn mq_list_consumers_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
sub: String,
) -> Result<Vec<ConsumerInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_consumers(&topic, &sub).await
}
pub async fn mq_unload_topic_core(state: &AppState, conn_id: &str, topic: TopicRef) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Unload topic").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.unload_topic(&topic).await
}
// ---- Rate limits / quotas / retention ----
pub async fn mq_set_publish_rate_core(
state: &AppState,
conn_id: &str,
scope: PolicyScope,
rate: PublishRate,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Set publish rate").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.set_publish_rate(&scope, rate).await
}
pub async fn mq_set_dispatch_rate_core(
state: &AppState,
conn_id: &str,
scope: PolicyScope,
rate: DispatchRate,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Set dispatch rate").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.set_dispatch_rate(&scope, rate).await
}
pub async fn mq_set_subscribe_rate_core(
state: &AppState,
conn_id: &str,
scope: PolicyScope,
rate: SubscribeRate,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Set subscribe rate").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.set_subscribe_rate(&scope, rate).await
}
pub async fn mq_set_backlog_quota_core(
state: &AppState,
conn_id: &str,
scope: PolicyScope,
quota: BacklogQuota,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Set backlog quota").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.set_backlog_quota(&scope, quota).await
}
pub async fn mq_set_retention_core(
state: &AppState,
conn_id: &str,
scope: PolicyScope,
retention: RetentionPolicy,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Set retention").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.set_retention(&scope, retention).await
}
pub async fn mq_get_effective_policies_core(
state: &AppState,
conn_id: &str,
scope: PolicyScope,
) -> Result<serde_json::Value, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.get_effective_policies(&scope).await
}
// ---- Permissions ----
pub async fn mq_grant_permission_core(
state: &AppState,
conn_id: &str,
scope: PolicyScope,
role: String,
actions: Vec<AuthAction>,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Grant permission").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.grant_permission(&scope, &role, actions).await
}
pub async fn mq_revoke_permission_core(
state: &AppState,
conn_id: &str,
scope: PolicyScope,
role: String,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Revoke permission").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.revoke_permission(&scope, &role).await
}
pub async fn mq_list_permissions_core(
state: &AppState,
conn_id: &str,
scope: PolicyScope,
) -> Result<PermissionMap, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_permissions(&scope).await
}
// ---- Client tokens ----
pub async fn mq_issue_token_core(
state: &AppState,
conn_id: &str,
req: MqTokenIssueRequest,
) -> Result<MqIssuedToken, String> {
ensure_connection_writable(state, conn_id, "Issue MQ token").await?;
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
let mqc = MqAdminConfig::from_connection(&cfg)?;
let signing_config = mqc
.token_signing
.as_ref()
.filter(|config| config.is_configured())
.ok_or("Token signing is not configured for this MQ connection. Edit the connection and configure Broker Token signing with an HS256 secret or RS256 private key.")?;
let now = Utc::now();
let signed = sign_pulsar_token(signing_config, &req, now.timestamp())?;
let expires_at =
signed.expires_at_unix.and_then(|value| Utc.timestamp_opt(value, 0).single()).map(|value| value.to_rfc3339());
let record = MqTokenRecord {
id: Uuid::new_v4().to_string(),
connection_id: conn_id.to_string(),
subject: req.subject.trim().to_string(),
algorithm: signing_config.algorithm,
token_fingerprint: signed.fingerprint,
scope: req.scope.clone(),
actions: req.actions.clone(),
expires_at,
created_at: now.to_rfc3339(),
note: req.note.as_deref().unwrap_or_default().trim().to_string(),
};
state.storage.save_mq_token_record(&record).await?;
Ok(MqIssuedToken { token: signed.token, record })
}
pub async fn mq_list_token_records_core(
state: &AppState,
conn_id: &str,
subject: Option<String>,
) -> Result<Vec<MqTokenRecord>, String> {
if !state.configs.read().await.contains_key(conn_id) {
return Err("Connection not found".to_string());
}
let subject = subject.as_deref().map(str::trim).filter(|value| !value.is_empty());
state.storage.load_mq_token_records(conn_id, subject).await
}
// ---- Monitoring ----
pub async fn mq_get_backlog_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
sub: Option<String>,
) -> Result<BacklogStats, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.get_backlog(&topic, sub.as_deref()).await
}
// ---- Raw request (escape hatch) ----
pub async fn mq_raw_request_core(state: &AppState, conn_id: &str, req: MqRawRequest) -> Result<MqRawResponse, String> {
if req.is_mutating() {
ensure_connection_writable(state, conn_id, "MQ admin write").await?;
}
let adapter = get_adapter(state, conn_id).await?;
adapter.raw_request(req).await
}
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
async fn get_adapter(
state: &AppState,
conn_id: &str,
) -> Result<std::sync::Arc<dyn crate::mq::port::MessageQueueAdmin>, String> {
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?;
state.mq_registry.get_or_build_config(conn_id, mqc).await
}
async fn ensure_connection_writable(state: &AppState, conn_id: &str, operation: &str) -> Result<(), String> {
let configs = state.configs.read().await;
if let Some(config) = configs.get(conn_id) {
if config.read_only {
return Err(format!(
"Read-only mode: connection '{}' has read-only protection enabled. {} blocked.",
config.name, operation
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::connection::{ConnectionConfig, DatabaseType};
use crate::storage::Storage;
use std::time::{SystemTime, UNIX_EPOCH};
fn mq_connection(read_only: bool) -> ConnectionConfig {
ConnectionConfig {
id: "readonly-mq".to_string(),
name: "Read only MQ".to_string(),
db_type: DatabaseType::MessageQueue,
driver_profile: Some("pulsar".to_string()),
driver_label: Some("Apache Pulsar".to_string()),
url_params: None,
host: "127.0.0.1".to_string(),
port: 8080,
username: String::new(),
password: String::new(),
database: None,
visible_databases: None,
attached_databases: Vec::new(),
color: None,
transport_layers: Vec::new(),
connect_timeout_secs: 5,
query_timeout_secs: 30,
idle_timeout_secs: 60,
keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(),
ssl: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
sysdba: false,
oracle_connection_type: None,
connection_string: None,
redis_connection_mode: None,
redis_sentinel_master: String::new(),
redis_sentinel_nodes: String::new(),
redis_sentinel_username: String::new(),
redis_sentinel_password: String::new(),
redis_sentinel_tls: false,
redis_cluster_nodes: String::new(),
redis_key_separator: ":".to_string(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
external_config: Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://127.0.0.1:8080",
"auth": { "kind": "none" }
})),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only,
}
}
fn mq_connection_with_token_signing() -> ConnectionConfig {
let mut config = mq_connection(false);
config.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://127.0.0.1:8080",
"auth": { "kind": "none" },
"tokenSigning": {
"algorithm": "hs256",
"key": "broker-signing-secret"
}
}));
config
}
async fn test_state_with(config: ConnectionConfig) -> (AppState, std::path::PathBuf) {
let stamp =
SystemTime::now().duration_since(UNIX_EPOCH).expect("system time should be after UNIX epoch").as_nanos();
let dir = std::env::temp_dir().join(format!("dbx-mq-service-test-{stamp}"));
std::fs::create_dir_all(&dir).expect("failed to create test directory");
let storage = Storage::open(&dir.join("storage.db")).await.expect("failed to open test storage");
let state = AppState::new_with_plugin_dir(storage, dir.join("plugins"));
state.configs.write().await.insert(config.id.clone(), config);
(state, dir)
}
#[tokio::test]
async fn mutating_service_calls_block_read_only_connections_before_adapter_build() {
let (state, dir) = test_state_with(mq_connection(true)).await;
let err = mq_create_topic_core(
&state,
"readonly-mq",
TopicRef {
tenant: "public".to_string(),
namespace: "default".to_string(),
topic: "orders".to_string(),
persistent: true,
partitioned: None,
},
None,
)
.await
.expect_err("read-only write should fail");
assert!(err.contains("Read-only mode"));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn mutating_raw_requests_block_read_only_connections_before_adapter_build() {
let (state, dir) = test_state_with(mq_connection(true)).await;
let err = mq_raw_request_core(
&state,
"readonly-mq",
MqRawRequest {
method: "POST".to_string(),
path: "/admin/v2/tenants".to_string(),
query: None,
body: Some(serde_json::json!({ "adminRoles": [] })),
},
)
.await
.expect_err("read-only raw write should fail");
assert!(err.contains("Read-only mode"));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn peek_messages_rejects_counts_above_service_limit_before_adapter_call() {
let (state, dir) = test_state_with(mq_connection(false)).await;
let err = mq_peek_messages_core(
&state,
"readonly-mq",
TopicRef {
tenant: "public".to_string(),
namespace: "default".to_string(),
topic: "orders".to_string(),
persistent: true,
partitioned: None,
},
"sub-a".to_string(),
101,
)
.await
.expect_err("peek count above the service limit should fail");
assert!(err.contains("between 1 and 100"));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn issue_token_saves_record_without_plaintext() {
let (state, dir) = test_state_with(mq_connection_with_token_signing()).await;
let issued = mq_issue_token_core(
&state,
"readonly-mq",
MqTokenIssueRequest {
subject: "rt-erp-server".to_string(),
expires_in_seconds: Some(3600),
scope: None,
actions: vec![AuthAction::Consume],
note: Some("integration test".to_string()),
},
)
.await
.expect("token issuance should succeed");
assert!(!issued.token.is_empty());
assert_eq!(issued.record.subject, "rt-erp-server");
let records = mq_list_token_records_core(&state, "readonly-mq", Some("rt-erp-server".to_string()))
.await
.expect("listing token records should succeed");
assert_eq!(records.len(), 1);
assert_eq!(records[0], issued.record);
let record_json = serde_json::to_value(&records[0]).expect("record should serialize to JSON");
assert!(record_json.get("token").is_none());
let _ = std::fs::remove_dir_all(dir);
}
}
+121
View File
@@ -0,0 +1,121 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::mq::types::{MqTokenIssueRequest, MqTokenSigningAlgorithm, MqTokenSigningConfig};
#[derive(Debug, Clone)]
pub struct SignedPulsarToken {
pub token: String,
pub fingerprint: String,
pub expires_at_unix: Option<i64>,
}
#[derive(Debug, Serialize)]
struct PulsarTokenClaims<'a> {
sub: &'a str,
iat: i64,
#[serde(skip_serializing_if = "Option::is_none")]
exp: Option<i64>,
}
pub fn sign_pulsar_token(
config: &MqTokenSigningConfig,
req: &MqTokenIssueRequest,
now_unix: i64,
) -> Result<SignedPulsarToken, String> {
let subject = req.subject.trim();
if subject.is_empty() {
return Err("Token subject is required".to_string());
}
// Validate subject format
if subject.len() > 256 {
return Err("Token subject exceeds maximum length of 256 characters".to_string());
}
// Pulsar typically accepts alphanumeric characters plus common separators
// Allow: letters, digits, hyphen, underscore, dot, @
if !subject.chars().all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '@')) {
return Err(format!(
"Token subject contains invalid characters. Only alphanumeric, '-', '_', '.', and '@' are allowed. Got: {}",
subject
));
}
let key = config.key.trim();
if key.is_empty() {
return Err("Token signing key is required".to_string());
}
let expires_at_unix = match req.expires_in_seconds {
Some(seconds) if seconds <= 0 => return Err("Token expiry must be greater than zero seconds".to_string()),
Some(seconds) => Some(now_unix.saturating_add(seconds)),
None => None,
};
let claims = PulsarTokenClaims { sub: subject, iat: now_unix, exp: expires_at_unix };
let algorithm = jwt_algorithm(config.algorithm);
let header = Header::new(algorithm);
let encoding_key = encoding_key(config.algorithm, key)?;
let token = encode(&header, &claims, &encoding_key).map_err(|err| format!("Failed to sign Pulsar token: {err}"))?;
let fingerprint = token_fingerprint(&token);
Ok(SignedPulsarToken { token, fingerprint, expires_at_unix })
}
pub fn token_fingerprint(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
format!("sha256:{}", hex_lower(&digest))
}
fn jwt_algorithm(algorithm: MqTokenSigningAlgorithm) -> Algorithm {
match algorithm {
MqTokenSigningAlgorithm::Hs256 => Algorithm::HS256,
MqTokenSigningAlgorithm::Rs256 => Algorithm::RS256,
}
}
fn encoding_key(algorithm: MqTokenSigningAlgorithm, key: &str) -> Result<EncodingKey, String> {
match algorithm {
MqTokenSigningAlgorithm::Hs256 => Ok(EncodingKey::from_secret(key.as_bytes())),
MqTokenSigningAlgorithm::Rs256 => {
let normalized = normalize_rsa_key_material(key)?;
EncodingKey::from_rsa_pem(normalized.as_bytes()).map_err(|err| format!("Invalid RS256 private key: {err}"))
}
}
}
fn normalize_rsa_key_material(key: &str) -> Result<String, String> {
// If already in PEM format, return as-is
if key.contains("-----BEGIN") {
return Ok(key.to_string());
}
// Try to decode as base64
match BASE64.decode(key.as_bytes()) {
Ok(bytes) => String::from_utf8(bytes).map_err(|e| {
format!(
"RSA key appears to be base64-encoded but decodes to invalid UTF-8: {}. \
Please provide the key in PEM format (with -----BEGIN/END headers) or as valid base64.",
e
)
}),
Err(e) => Err(format!(
"RSA key is not in PEM format and failed to decode as base64: {}. \
Please provide the key in PEM format (with -----BEGIN RSA PRIVATE KEY----- headers) \
or as valid base64-encoded data.",
e
)),
}
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
+564
View File
@@ -0,0 +1,564 @@
//! Shared resource, statistics, and configuration types for the message queue
//! admin console. All types are `serde`-serializable in `camelCase` so they map
//! 1:1 to the frontend TypeScript definitions in `apps/desktop/src/types/mq.ts`.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Which message queue system an admin connection targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MqSystemKind {
Pulsar,
Kafka,
#[serde(rename = "rocketmq")]
RocketMq,
}
impl MqSystemKind {
pub fn as_str(self) -> &'static str {
match self {
MqSystemKind::Pulsar => "pulsar",
MqSystemKind::Kafka => "kafka",
MqSystemKind::RocketMq => "rocketmq",
}
}
}
/// Capability flags. The frontend reads these to show/hide functionality, and
/// the adapter computes them from the detected server version so unsupported
/// features are hidden rather than failing at call time.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqCapabilities {
pub supports_tenants: bool,
pub supports_namespaces: bool,
pub supports_partitioned_topics: bool,
pub supports_subscriptions: bool,
pub supports_create_subscription: bool,
pub supports_reset_cursor: bool,
pub supports_skip_messages: bool,
pub supports_clear_backlog: bool,
pub supports_peek_messages: bool,
pub supports_expire_messages: bool,
pub supports_rate_limits: bool,
pub supports_backlog_quota: bool,
pub supports_retention: bool,
pub supports_permissions: bool,
pub supports_geo_replication: bool,
pub supports_token_management: bool,
pub supports_raw_admin_api: bool,
}
impl Default for MqCapabilities {
fn default() -> Self {
Self {
supports_tenants: false,
supports_namespaces: false,
supports_partitioned_topics: false,
supports_subscriptions: false,
supports_create_subscription: false,
supports_reset_cursor: false,
supports_skip_messages: false,
supports_clear_backlog: false,
supports_peek_messages: false,
supports_expire_messages: false,
supports_rate_limits: false,
supports_backlog_quota: false,
supports_retention: false,
supports_permissions: false,
supports_geo_replication: false,
supports_token_management: false,
supports_raw_admin_api: false,
}
}
}
/// Result of a connectivity test, including the detected server version and how
/// it was determined (probe vs. fallback) so the UI can warn appropriately.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqClusterInfo {
pub system_kind: MqSystemKind,
/// Raw version string reported by the broker (e.g. `3.1.2`), if available.
pub server_version: Option<String>,
/// Version profile the adapter resolved to (e.g. `3.1.x`).
pub resolved_profile: String,
/// How the version was determined: `probed` | `pinned` | `fallback`.
pub version_detection: String,
pub capabilities: MqCapabilities,
/// Optional free-form extra fields (cluster list, broker count, ...).
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub extra: serde_json::Value,
}
// ---------------------------------------------------------------------------
// Token signing
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MqTokenSigningAlgorithm {
Hs256,
Rs256,
}
impl MqTokenSigningAlgorithm {
pub fn as_str(self) -> &'static str {
match self {
Self::Hs256 => "hs256",
Self::Rs256 => "rs256",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqTokenSigningConfig {
pub algorithm: MqTokenSigningAlgorithm,
#[serde(default)]
pub key: String,
}
impl MqTokenSigningConfig {
pub fn is_configured(&self) -> bool {
!self.key.trim().is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqTokenIssueRequest {
pub subject: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_in_seconds: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<PolicyScope>,
#[serde(default)]
pub actions: Vec<AuthAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqTokenRecord {
pub id: String,
pub connection_id: String,
pub subject: String,
pub algorithm: MqTokenSigningAlgorithm,
pub token_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<PolicyScope>,
#[serde(default)]
pub actions: Vec<AuthAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
pub created_at: String,
#[serde(default)]
pub note: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqIssuedToken {
pub token: String,
pub record: MqTokenRecord,
}
// ---------------------------------------------------------------------------
// Tenant
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TenantInfo {
pub name: String,
#[serde(default)]
pub admin_roles: Vec<String>,
#[serde(default)]
pub allowed_clusters: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TenantConfig {
#[serde(default)]
pub admin_roles: Vec<String>,
#[serde(default)]
pub allowed_clusters: Vec<String>,
}
// ---------------------------------------------------------------------------
// Namespace
// ---------------------------------------------------------------------------
/// A fully-qualified namespace reference (`tenant/namespace`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NamespaceRef {
pub tenant: String,
pub namespace: String,
}
impl NamespaceRef {
/// `tenant/namespace`
pub fn path(&self) -> String {
format!("{}/{}", self.tenant, self.namespace)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NamespaceInfo {
pub tenant: String,
pub namespace: String,
#[serde(default)]
pub admin_roles: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NamespaceConfig {
/// Replication clusters to bootstrap the namespace with. Pulsar requires at
/// least one when the cluster runs with geo-replication.
#[serde(default)]
pub clusters: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bundles: Option<u32>,
}
// ---------------------------------------------------------------------------
// Topic
// ---------------------------------------------------------------------------
/// A fully-qualified topic reference.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TopicRef {
pub tenant: String,
pub namespace: String,
pub topic: String,
/// Whether the topic is persistent (`persistent://`) or not
/// (`non-persistent://`). Defaults to persistent.
#[serde(default = "default_true")]
pub persistent: bool,
/// Optional UI hint used to prefer partitioned stats when the topic came
/// from the partitioned topic list.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partitioned: Option<bool>,
}
fn default_true() -> bool {
true
}
impl TopicRef {
/// The URL domain segment: `persistent` or `non-persistent`.
pub fn domain(&self) -> &'static str {
if self.persistent {
"persistent"
} else {
"non-persistent"
}
}
/// `{tenant}/{namespace}/{topic}` — the path used by most Pulsar endpoints.
pub fn path(&self) -> String {
format!("{}/{}/{}", self.tenant, self.namespace, self.topic)
}
/// Full topic name, e.g. `persistent://public/default/orders`.
pub fn full_name(&self) -> String {
format!("{}://{}", self.domain(), self.path())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TopicInfo {
/// Full topic name, e.g. `persistent://public/default/orders`.
pub name: String,
/// Short topic name without the namespace prefix.
pub short_name: String,
pub partitioned: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partitions: Option<u32>,
pub persistent: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListTopicsOpts {
/// Include non-persistent topics in the listing.
#[serde(default)]
pub include_non_persistent: bool,
}
/// Aggregated, UI-friendly topic statistics. Parsed from the version-specific
/// raw stats payload by the adapter's version profile.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TopicStats {
pub msg_rate_in: f64,
pub msg_rate_out: f64,
pub msg_throughput_in: f64,
pub msg_throughput_out: f64,
pub storage_size: i64,
pub backlog_size: i64,
pub msg_in_counter: i64,
pub msg_out_counter: i64,
pub subscription_count: u32,
pub producer_count: u32,
/// Original raw stats JSON, for the detail view / advanced inspection.
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub raw: serde_json::Value,
}
// ---------------------------------------------------------------------------
// Subscription / consumers / producers
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionInfo {
pub name: String,
#[serde(default)]
pub sub_type: String,
pub msg_backlog: i64,
pub msg_rate_out: f64,
pub msg_throughput_out: f64,
#[serde(default)]
pub consumers: Vec<ConsumerInfo>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsumerInfo {
pub consumer_name: String,
pub msg_rate_out: f64,
pub msg_throughput_out: f64,
pub available_permits: i64,
#[serde(default)]
pub address: String,
#[serde(default)]
pub client_version: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProducerInfo {
pub producer_id: i64,
pub producer_name: String,
pub msg_rate_in: f64,
pub msg_throughput_in: f64,
#[serde(default)]
pub address: String,
#[serde(default)]
pub client_version: String,
}
/// Where to position a cursor when creating a subscription or resetting it.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ResetPosition {
/// Earliest available message.
Earliest,
/// Latest message (skip the existing backlog).
Latest,
/// A specific point in time (milliseconds since epoch).
Timestamp { timestamp_ms: i64 },
/// A specific message id.
MessageId { ledger_id: i64, entry_id: i64 },
}
/// How many messages to skip on a subscription.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum SkipCount {
All,
Count { count: u32 },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BacklogStats {
pub msg_backlog: i64,
pub backlog_size: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PeekedMessage {
/// 1-based subscription position passed to the Pulsar Admin API.
pub position: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publish_time: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event_time: Option<String>,
#[serde(default)]
pub properties: HashMap<String, String>,
#[serde(default)]
pub headers: HashMap<String, String>,
/// Message payload encoded as base64 so binary messages are preserved.
pub payload_base64: String,
/// UTF-8 preview when the payload can be decoded losslessly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload_text: Option<String>,
}
// ---------------------------------------------------------------------------
// Policy scope (rate limits / quotas / permissions)
// ---------------------------------------------------------------------------
/// Whether a policy applies at the namespace or topic level. Lets the rate
/// limit / quota / permission methods avoid duplicating namespace vs. topic
/// variants.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "level", rename_all = "camelCase")]
pub enum PolicyScope {
Namespace { tenant: String, namespace: String },
Topic { tenant: String, namespace: String, topic: String, persistent: bool },
}
impl PolicyScope {
pub fn as_namespace_ref(&self) -> Option<NamespaceRef> {
match self {
PolicyScope::Namespace { tenant, namespace } => {
Some(NamespaceRef { tenant: tenant.clone(), namespace: namespace.clone() })
}
_ => None,
}
}
pub fn as_topic_ref(&self) -> Option<TopicRef> {
match self {
PolicyScope::Topic { tenant, namespace, topic, persistent } => Some(TopicRef {
tenant: tenant.clone(),
namespace: namespace.clone(),
topic: topic.clone(),
persistent: *persistent,
partitioned: None,
}),
_ => None,
}
}
pub fn is_topic(&self) -> bool {
matches!(self, PolicyScope::Topic { .. })
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishRate {
pub publish_throttling_rate_in_msg: i32,
pub publish_throttling_rate_in_byte: i64,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DispatchRate {
pub dispatch_throttling_rate_in_msg: i32,
pub dispatch_throttling_rate_in_byte: i64,
#[serde(default = "default_rate_period")]
pub rate_period_in_second: i32,
}
fn default_rate_period() -> i32 {
1
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscribeRate {
pub subscribe_throttling_rate_per_consumer: i32,
#[serde(default = "default_rate_period")]
pub rate_period_in_second: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BacklogQuota {
/// Limit in bytes. `-1` means no size limit.
pub limit_size: i64,
/// Limit in seconds. `-1` means no time limit.
#[serde(default)]
pub limit_time: i32,
/// `producer_request_hold` | `producer_exception` | `consumer_backlog_eviction`.
pub policy: String,
/// `destination_storage` (size) or `message_age` (time).
#[serde(default = "default_backlog_quota_type")]
pub quota_type: String,
}
fn default_backlog_quota_type() -> String {
"destination_storage".to_string()
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RetentionPolicy {
/// Retention time in minutes. `-1` = infinite.
pub retention_time_in_minutes: i32,
/// Retention size in MB. `-1` = infinite.
pub retention_size_in_mb: i32,
}
/// Authorization actions that can be granted to a role on a namespace/topic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthAction {
Produce,
Consume,
Functions,
Sources,
Sinks,
#[serde(rename = "packages")]
Packages,
}
pub type PermissionMap = HashMap<String, Vec<AuthAction>>;
// ---------------------------------------------------------------------------
// Raw request (escape hatch)
// ---------------------------------------------------------------------------
/// A raw admin REST request, proxied through the adapter to cover any endpoint
/// the typed methods do not. The path is appended to the connection's
/// `admin_url` base — arbitrary hosts are not allowed (SSRF guard).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqRawRequest {
/// HTTP method: GET / PUT / POST / DELETE.
pub method: String,
/// Path relative to the admin base, e.g. `/admin/v2/tenants`.
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<serde_json::Value>,
}
impl MqRawRequest {
/// Whether this request mutates server state and therefore must pass the
/// read-only protection check.
pub fn is_mutating(&self) -> bool {
!matches!(self.method.to_ascii_uppercase().as_str(), "GET" | "HEAD" | "OPTIONS")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqRawResponse {
pub status: u16,
pub body: serde_json::Value,
/// Set when the response body was not valid JSON; carries the raw text.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
+39
View File
@@ -0,0 +1,39 @@
//! Shared utilities for the message queue admin module.
/// Truncate a string to `max` bytes, respecting UTF-8 char boundaries, and
/// append an ellipsis when truncation occurs.
pub(crate) fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &s[..end])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_short_string_unchanged() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_long_string_adds_ellipsis() {
let result = truncate("abcdefghij", 5);
assert_eq!(result, "abcde…");
}
#[test]
fn truncate_handles_multibyte_utf8_boundary() {
let body = format!("a{}", "错".repeat(100));
let truncated = truncate(&body, 10);
assert!(truncated.ends_with('…'));
assert!(truncated.len() <= 10 + "…".len());
}
}
+2
View File
@@ -924,6 +924,7 @@ pub async fn do_execute(
}
PoolKind::Redis(_) => Err("Use Redis-specific commands".to_string()),
PoolKind::MongoDb(_) => Err("Use MongoDB-specific commands".to_string()),
PoolKind::MessageQueue => Err("Use Message Queue-specific commands".to_string()),
PoolKind::InfluxDb(client) => {
let client = client.clone();
let database = pool_key.split(':').nth(1).unwrap_or("default").to_string();
@@ -1585,6 +1586,7 @@ pub async fn execute_statements_in_transaction(
| PoolKind::Turso(_)
| PoolKind::SqlServer(_)
| PoolKind::Agent(_) => TxPath::Explicit,
PoolKind::MessageQueue => TxPath::None,
#[cfg(feature = "duckdb-bundled")]
PoolKind::DuckDb(_)
| PoolKind::Redis(_)
+542 -1
View File
@@ -6,9 +6,13 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::ai::{AiChatMessage, AiConfig, AiConversation};
use crate::connection_secrets::{
MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX,
MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, MQ_TOKEN_SIGNING_SECRET_PREFIX,
};
use crate::db::sqlite::{connect_path_create_if_missing, SqliteHandle};
use crate::history::{HistoryEntry, MAX_HISTORY};
use crate::models::connection::{ConnectionConfig, TransportLayerConfig};
use crate::models::connection::{ConnectionConfig, DatabaseType, TransportLayerConfig};
use crate::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary};
const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels.";
@@ -136,6 +140,22 @@ const SCHEMA_STATEMENTS: &[&str] = &[
byte_size INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS mq_token_records (
id TEXT PRIMARY KEY,
connection_id TEXT NOT NULL,
subject TEXT NOT NULL,
algorithm TEXT NOT NULL,
token_fingerprint TEXT NOT NULL,
scope_json TEXT,
actions_json TEXT NOT NULL DEFAULT '[]',
expires_at TEXT,
created_at TEXT NOT NULL,
note TEXT NOT NULL DEFAULT ''
)",
"CREATE INDEX IF NOT EXISTS idx_mq_token_records_connection_subject
ON mq_token_records (connection_id, subject, created_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_mq_token_records_fingerprint
ON mq_token_records (token_fingerprint)",
"CREATE TABLE IF NOT EXISTS saved_sql_folders (
id TEXT PRIMARY KEY,
connection_id TEXT NOT NULL,
@@ -302,6 +322,32 @@ fn scrub_transport_layer_secrets(config: &mut ConnectionConfig) {
}
}
fn scrub_mq_auth_secrets(config: &mut ConnectionConfig) {
if config.db_type != DatabaseType::MessageQueue {
return;
}
let Some(auth) = mq_auth_object_mut(config.external_config.as_mut()) else {
return;
};
match mq_auth_kind(auth) {
Some("token") => scrub_json_secret(auth, "token"),
Some("basic") => scrub_json_secret(auth, "password"),
Some(kind) if is_api_key_auth_kind(kind) => scrub_json_secret(auth, "value"),
Some("oauth2") => scrub_json_secret(auth, "clientSecret"),
_ => {}
}
}
fn scrub_mq_token_signing_secret(config: &mut ConnectionConfig) {
if config.db_type != DatabaseType::MessageQueue {
return;
}
let Some(signing) = mq_token_signing_object_mut(config.external_config.as_mut()) else {
return;
};
scrub_json_secret(signing, "key");
}
fn delete_secret_prefix_in_tx(
tx: &rusqlite::Transaction<'_>,
connection_id: &str,
@@ -736,6 +782,8 @@ impl Storage {
scrub_transport_layer_secrets(&mut sanitized);
sanitized.redis_sentinel_password = String::new();
sanitized.connection_string = None;
scrub_mq_auth_secrets(&mut sanitized);
scrub_mq_token_signing_secret(&mut sanitized);
let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json])
@@ -770,6 +818,8 @@ impl Storage {
scrub_transport_layer_secrets(&mut sanitized);
sanitized.redis_sentinel_password = String::new();
sanitized.connection_string = None;
scrub_mq_auth_secrets(&mut sanitized);
scrub_mq_token_signing_secret(&mut sanitized);
let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json])
@@ -817,6 +867,8 @@ impl Storage {
)
.map_err(|e| e.to_string())?;
}
persist_mq_auth_secrets_in_tx(&tx, &config)?;
persist_mq_token_signing_secret_in_tx(&tx, &config)?;
}
if configs.is_empty() {
@@ -895,10 +947,73 @@ impl Storage {
}
config.redis_sentinel_password = self.get_secret(&id, "redis_sentinel_password").await?.unwrap_or_default();
config.connection_string = self.get_secret(&id, "connection_string").await?;
let needs_mq_auth_rewrite = self.hydrate_mq_auth_secrets(&id, &mut config).await?;
let needs_mq_token_signing_rewrite = self.hydrate_mq_token_signing_secret(&id, &mut config).await?;
let needs_mq_secret_rewrite = needs_mq_auth_rewrite || needs_mq_token_signing_rewrite;
if needs_mq_secret_rewrite {
let mut sanitized = config.clone().canonicalized();
scrub_mq_auth_secrets(&mut sanitized);
scrub_mq_token_signing_secret(&mut sanitized);
let sanitized_json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
let update_id = id.clone();
self.with_conn(move |conn| {
conn.execute(
"UPDATE connections SET config_json = ?1 WHERE id = ?2",
params![sanitized_json, update_id],
)
.map(|_| ())
.map_err(|e| e.to_string())
})
.await?;
}
configs.push(config.canonicalized());
}
Ok(configs)
}
async fn hydrate_mq_auth_secrets(
&self,
connection_id: &str,
config: &mut ConnectionConfig,
) -> Result<bool, String> {
if config.db_type != DatabaseType::MessageQueue {
return Ok(false);
}
let Some(auth) = mq_auth_object_mut(config.external_config.as_mut()) else {
return Ok(false);
};
let needs_rewrite = match mq_auth_kind(auth) {
Some("token") => hydrate_mq_json_secret(self, connection_id, MQ_AUTH_TOKEN_KEY, auth, "token").await?,
Some("basic") => {
hydrate_mq_json_secret(self, connection_id, MQ_AUTH_PASSWORD_KEY, auth, "password").await?
}
Some(kind) if is_api_key_auth_kind(kind) => {
hydrate_mq_json_secret(self, connection_id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value").await?
}
Some("oauth2") => {
hydrate_mq_json_secret(self, connection_id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret").await?
}
_ => false,
};
Ok(needs_rewrite)
}
async fn hydrate_mq_token_signing_secret(
&self,
connection_id: &str,
config: &mut ConnectionConfig,
) -> Result<bool, String> {
if config.db_type != DatabaseType::MessageQueue {
return Ok(false);
}
let Some(signing) = mq_token_signing_object_mut(config.external_config.as_mut()) else {
return Ok(false);
};
hydrate_mq_json_secret(self, connection_id, MQ_TOKEN_SIGNING_KEY, signing, "key").await
}
}
// Saved SQL
@@ -1166,6 +1281,76 @@ impl Storage {
}
}
// MQ token records
#[cfg(feature = "mq-admin")]
impl Storage {
pub async fn save_mq_token_record(&self, record: &crate::mq::MqTokenRecord) -> Result<(), String> {
let record = record.clone();
self.with_conn(move |conn| {
let scope_json = record
.scope
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|e| e.to_string())?;
let actions_json = serde_json::to_string(&record.actions).map_err(|e| e.to_string())?;
conn.execute(
"INSERT OR REPLACE INTO mq_token_records \
(id, connection_id, subject, algorithm, token_fingerprint, scope_json, actions_json, expires_at, created_at, note) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
record.id,
record.connection_id,
record.subject,
record.algorithm.as_str(),
record.token_fingerprint,
scope_json,
actions_json,
record.expires_at,
record.created_at,
record.note
],
)
.map(|_| ())
.map_err(|e| e.to_string())
})
.await
}
pub async fn load_mq_token_records(
&self,
connection_id: &str,
subject: Option<&str>,
) -> Result<Vec<crate::mq::MqTokenRecord>, String> {
let connection_id = connection_id.to_string();
let subject = subject.map(str::to_string);
self.with_conn(move |conn| {
let sql = if subject.is_some() {
"SELECT id, connection_id, subject, algorithm, token_fingerprint, scope_json, actions_json, expires_at, created_at, note \
FROM mq_token_records WHERE connection_id = ?1 AND subject = ?2 ORDER BY created_at DESC"
} else {
"SELECT id, connection_id, subject, algorithm, token_fingerprint, scope_json, actions_json, expires_at, created_at, note \
FROM mq_token_records WHERE connection_id = ?1 ORDER BY created_at DESC"
};
let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
let rows = if let Some(subject) = subject {
stmt.query_map(params![connection_id, subject], mq_token_record_from_row)
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
} else {
stmt.query_map(params![connection_id], mq_token_record_from_row)
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
};
Ok(rows)
})
.await
}
}
// Layout
impl Storage {
@@ -1480,6 +1665,163 @@ fn persist_secret_in_tx(
Ok(())
}
fn persist_mq_auth_secrets_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionConfig) -> Result<(), String> {
if config.db_type != DatabaseType::MessageQueue {
delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?;
return Ok(());
}
let Some(auth) = mq_auth_object(config.external_config.as_ref()) else {
delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?;
return Ok(());
};
match mq_auth_kind(auth) {
Some("none") => delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?,
Some("token") => replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_TOKEN_KEY, auth, "token")?,
Some("basic") => replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_PASSWORD_KEY, auth, "password")?,
Some(kind) if is_api_key_auth_kind(kind) => {
replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value")?
}
Some("oauth2") => {
replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret")?
}
_ => delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?,
}
Ok(())
}
fn replace_mq_auth_secret_in_tx(
tx: &rusqlite::Transaction<'_>,
connection_id: &str,
key: &str,
auth: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<(), String> {
let current = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty());
let existing = if current.is_none() { get_secret_in_tx(tx, connection_id, key)? } else { None };
delete_secret_prefix_in_tx(tx, connection_id, MQ_AUTH_SECRET_PREFIX)?;
match current {
Some(secret) => persist_secret_in_tx(tx, connection_id, key, secret),
None => match existing {
Some(secret) => persist_secret_in_tx(tx, connection_id, key, &secret),
None => Ok(()),
},
}
}
fn get_secret_in_tx(tx: &rusqlite::Transaction<'_>, connection_id: &str, key: &str) -> Result<Option<String>, String> {
tx.query_row(
"SELECT secret FROM connection_secrets WHERE connection_id = ?1 AND key = ?2",
params![connection_id, key],
|row| row.get(0),
)
.optional()
.map_err(|e| e.to_string())
}
fn persist_mq_token_signing_secret_in_tx(
tx: &rusqlite::Transaction<'_>,
config: &ConnectionConfig,
) -> Result<(), String> {
if config.db_type != DatabaseType::MessageQueue {
delete_secret_prefix_in_tx(tx, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
return Ok(());
}
let Some(signing) = mq_token_signing_object(config.external_config.as_ref()) else {
delete_secret_prefix_in_tx(tx, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
return Ok(());
};
persist_json_secret_if_present_in_tx(tx, &config.id, MQ_TOKEN_SIGNING_KEY, signing, "key")
}
fn persist_json_secret_if_present_in_tx(
tx: &rusqlite::Transaction<'_>,
connection_id: &str,
key: &str,
auth: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<(), String> {
if let Some(secret) = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()) {
persist_secret_in_tx(tx, connection_id, key, secret)?;
}
Ok(())
}
async fn hydrate_mq_json_secret(
storage: &Storage,
connection_id: &str,
key: &str,
auth: &mut serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<bool, String> {
if let Some(secret) = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()) {
storage.set_secret(connection_id, key, secret).await?;
Ok(true)
} else if let Some(secret) = storage.get_secret(connection_id, key).await? {
auth.insert(field.to_string(), serde_json::Value::String(secret));
Ok(false)
} else {
Ok(false)
}
}
fn scrub_json_secret(auth: &mut serde_json::Map<String, serde_json::Value>, field: &str) {
if auth.contains_key(field) {
auth.insert(field.to_string(), serde_json::Value::String(String::new()));
}
}
fn mq_auth_kind(auth: &serde_json::Map<String, serde_json::Value>) -> Option<&str> {
auth.get("kind").and_then(serde_json::Value::as_str)
}
fn mq_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
value?.get("auth")?.as_object()
}
fn mq_auth_object_mut(
value: Option<&mut serde_json::Value>,
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
value?.get_mut("auth")?.as_object_mut()
}
fn mq_token_signing_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
value?.get("tokenSigning")?.as_object()
}
fn mq_token_signing_object_mut(
value: Option<&mut serde_json::Value>,
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
value?.get_mut("tokenSigning")?.as_object_mut()
}
fn is_api_key_auth_kind(kind: &str) -> bool {
matches!(kind, "apiKey" | "api_key" | "apikey")
}
#[cfg(feature = "mq-admin")]
fn mq_token_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<crate::mq::MqTokenRecord> {
let algorithm: String = row.get(3)?;
let scope_json: Option<String> = row.get(5)?;
let actions_json: String = row.get(6)?;
Ok(crate::mq::MqTokenRecord {
id: row.get(0)?,
connection_id: row.get(1)?,
subject: row.get(2)?,
algorithm: serde_json::from_value(serde_json::Value::String(algorithm)).map_err(map_from_sql_err)?,
token_fingerprint: row.get(4)?,
scope: scope_json.as_deref().map(serde_json::from_str).transpose().map_err(map_from_sql_err)?,
actions: serde_json::from_str(&actions_json).map_err(map_from_sql_err)?,
expires_at: row.get(7)?,
created_at: row.get(8)?,
note: row.get(9)?,
})
}
fn map_from_sql_err(err: serde_json::Error) -> rusqlite::Error {
rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(err))
}
@@ -1487,6 +1829,8 @@ fn map_from_sql_err(err: serde_json::Error) -> rusqlite::Error {
#[cfg(test)]
mod tests {
use super::{DesktopIconTheme, DesktopSettings, Storage};
use crate::connection_secrets::{MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY};
use crate::models::connection::{ConnectionConfig, DatabaseType};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_db_path(name: &str) -> std::path::PathBuf {
@@ -1494,6 +1838,203 @@ mod tests {
std::env::temp_dir().join(format!("dbx-storage-{name}-{}-{stamp}.db", std::process::id()))
}
fn mq_connection(id: &str, token: &str) -> ConnectionConfig {
ConnectionConfig {
id: id.to_string(),
name: "Pulsar".to_string(),
db_type: DatabaseType::MessageQueue,
driver_profile: Some("pulsar".to_string()),
driver_label: Some("Apache Pulsar".to_string()),
url_params: None,
host: "127.0.0.1".to_string(),
port: 8080,
username: String::new(),
password: String::new(),
database: None,
visible_databases: None,
attached_databases: Vec::new(),
color: None,
transport_layers: Vec::new(),
connect_timeout_secs: 30,
query_timeout_secs: 300,
idle_timeout_secs: 600,
keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(),
ssl: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
sysdba: false,
oracle_connection_type: None,
connection_string: None,
redis_connection_mode: None,
redis_sentinel_master: String::new(),
redis_sentinel_nodes: String::new(),
redis_sentinel_username: String::new(),
redis_sentinel_password: String::new(),
redis_sentinel_tls: false,
redis_cluster_nodes: String::new(),
redis_key_separator: ":".to_string(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
external_config: Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://127.0.0.1:8080",
"auth": {
"kind": "token",
"token": token
}
})),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
}
}
async fn raw_connection_json(storage: &Storage, id: &str) -> String {
let id = id.to_string();
storage
.with_conn(move |conn| {
conn.query_row("SELECT config_json FROM connections WHERE id = ?1", [id], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())
})
.await
.unwrap()
}
async fn insert_raw_connection(storage: &Storage, config: &ConnectionConfig) {
let id = config.id.clone();
let json = serde_json::to_string(config).unwrap();
storage
.with_conn(move |conn| {
conn.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", rusqlite::params![id, json])
.map(|_| ())
.map_err(|e| e.to_string())
})
.await
.unwrap();
}
fn mq_token(config: &ConnectionConfig) -> Option<&str> {
config.external_config.as_ref()?.get("auth")?.get("token")?.as_str()
}
fn mq_token_signing_key(config: &ConnectionConfig) -> Option<&str> {
config.external_config.as_ref()?.get("tokenSigning")?.get("key")?.as_str()
}
#[tokio::test]
async fn save_connections_moves_mq_auth_token_to_secret_table_and_restores_it() {
let path = temp_db_path("mq-token-secrets");
let storage = Storage::open(&path).await.unwrap();
storage.save_connections(&[mq_connection("pulsar", "mq-token-secret")]).await.unwrap();
let raw_json = raw_connection_json(&storage, "pulsar").await;
assert!(!raw_json.contains("mq-token-secret"));
let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap();
assert_eq!(mq_token(&persisted), Some(""));
assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap().as_deref(), Some("mq-token-secret"));
let loaded = storage.load_connections().await.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(mq_token(&loaded[0]), Some("mq-token-secret"));
}
#[tokio::test]
async fn metadata_save_scrubs_mq_auth_token_and_preserves_existing_secret() {
let path = temp_db_path("mq-token-metadata");
let storage = Storage::open(&path).await.unwrap();
let original = mq_connection("pulsar", "existing-token");
storage.save_connections(&[original.clone()]).await.unwrap();
let mut metadata = original;
metadata.name = "Pulsar renamed".to_string();
if let Some(auth) = metadata.external_config.as_mut().and_then(|value| value.get_mut("auth")) {
auth["token"] = serde_json::Value::String("new-token-that-should-not-persist".to_string());
}
storage.save_connection_metadata_preserving_secrets(&[metadata]).await.unwrap();
let raw_json = raw_connection_json(&storage, "pulsar").await;
assert!(!raw_json.contains("existing-token"));
assert!(!raw_json.contains("new-token-that-should-not-persist"));
assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap().as_deref(), Some("existing-token"));
let loaded = storage.load_connections().await.unwrap();
assert_eq!(loaded[0].name, "Pulsar renamed");
assert_eq!(mq_token(&loaded[0]), Some("existing-token"));
}
#[tokio::test]
async fn load_connections_migrates_legacy_mq_auth_token_out_of_config_json() {
let path = temp_db_path("mq-token-legacy-migration");
let storage = Storage::open(&path).await.unwrap();
insert_raw_connection(&storage, &mq_connection("pulsar", "legacy-token")).await;
let loaded = storage.load_connections().await.unwrap();
assert_eq!(mq_token(&loaded[0]), Some("legacy-token"));
assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap().as_deref(), Some("legacy-token"));
let raw_json = raw_connection_json(&storage, "pulsar").await;
assert!(!raw_json.contains("legacy-token"));
let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap();
assert_eq!(mq_token(&persisted), Some(""));
}
#[tokio::test]
async fn save_connections_deletes_stale_mq_auth_secrets_when_kind_changes() {
let path = temp_db_path("mq-auth-kind-change");
let storage = Storage::open(&path).await.unwrap();
storage.save_connections(&[mq_connection("pulsar", "old-token")]).await.unwrap();
let mut config = mq_connection("pulsar", "");
config.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://127.0.0.1:8080",
"auth": {
"kind": "basic",
"username": "admin",
"password": "basic-secret"
}
}));
storage.save_connections(&[config]).await.unwrap();
assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap(), None);
assert_eq!(storage.get_secret("pulsar", MQ_AUTH_PASSWORD_KEY).await.unwrap().as_deref(), Some("basic-secret"));
}
#[tokio::test]
async fn save_connections_moves_mq_token_signing_key_to_secret_table_and_restores_it() {
let path = temp_db_path("mq-token-signing-secret");
let storage = Storage::open(&path).await.unwrap();
let mut config = mq_connection("pulsar", "");
config.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://127.0.0.1:8080",
"auth": { "kind": "none" },
"tokenSigning": {
"algorithm": "hs256",
"key": "broker-signing-secret"
}
}));
storage.save_connections(&[config]).await.unwrap();
let raw_json = raw_connection_json(&storage, "pulsar").await;
assert!(!raw_json.contains("broker-signing-secret"));
let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap();
assert_eq!(mq_token_signing_key(&persisted), Some(""));
assert_eq!(
storage.get_secret("pulsar", MQ_TOKEN_SIGNING_KEY).await.unwrap().as_deref(),
Some("broker-signing-secret")
);
let loaded = storage.load_connections().await.unwrap();
assert_eq!(mq_token_signing_key(&loaded[0]), Some("broker-signing-secret"));
}
#[tokio::test]
async fn desktop_settings_default_to_background_enabled() {
let path = temp_db_path("desktop-settings-default");
+73
View File
@@ -0,0 +1,73 @@
#![cfg(feature = "mq-admin")]
use std::time::{SystemTime, UNIX_EPOCH};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use dbx_core::mq::token::{sign_pulsar_token, token_fingerprint};
use dbx_core::mq::{MqTokenIssueRequest, MqTokenRecord, MqTokenSigningAlgorithm, MqTokenSigningConfig};
use dbx_core::storage::Storage;
#[test]
fn hs256_token_contains_pulsar_subject_and_expiry() {
let config = MqTokenSigningConfig { algorithm: MqTokenSigningAlgorithm::Hs256, key: "broker-secret".to_string() };
let req = MqTokenIssueRequest {
subject: "order-consumer".to_string(),
expires_in_seconds: Some(3600),
scope: None,
actions: vec![],
note: None,
};
let issued = sign_pulsar_token(&config, &req, 1_700_000_000).unwrap();
let payload = issued.token.split('.').nth(1).unwrap();
let payload: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap();
assert_eq!(payload.get("sub").and_then(serde_json::Value::as_str), Some("order-consumer"));
assert_eq!(payload.get("iat").and_then(serde_json::Value::as_i64), Some(1_700_000_000));
assert_eq!(payload.get("exp").and_then(serde_json::Value::as_i64), Some(1_700_003_600));
assert_eq!(issued.fingerprint, token_fingerprint(&issued.token));
}
#[test]
fn token_signing_requires_configured_key() {
let config = MqTokenSigningConfig { algorithm: MqTokenSigningAlgorithm::Hs256, key: " ".to_string() };
let req = MqTokenIssueRequest {
subject: "order-consumer".to_string(),
expires_in_seconds: None,
scope: None,
actions: vec![],
note: None,
};
let err = sign_pulsar_token(&config, &req, 1_700_000_000).unwrap_err();
assert!(err.contains("signing key"));
}
#[tokio::test]
async fn token_records_are_saved_without_token_plaintext() {
let storage = Storage::open(&temp_db_path("mq-token-records")).await.unwrap();
let record = MqTokenRecord {
id: "rec-1".to_string(),
connection_id: "conn-1".to_string(),
subject: "order-consumer".to_string(),
algorithm: MqTokenSigningAlgorithm::Hs256,
token_fingerprint: "sha256:abc".to_string(),
scope: None,
actions: vec![],
expires_at: Some("2026-06-14T12:00:00Z".to_string()),
created_at: "2026-06-14T11:00:00Z".to_string(),
note: "issued for test".to_string(),
};
storage.save_mq_token_record(&record).await.unwrap();
let records = storage.load_mq_token_records("conn-1", Some("order-consumer")).await.unwrap();
assert_eq!(records, vec![record]);
let json = serde_json::to_value(&records[0]).unwrap();
assert!(json.get("token").is_none());
}
fn temp_db_path(name: &str) -> std::path::PathBuf {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
std::env::temp_dir().join(format!("dbx-{name}-{}-{stamp}.db", std::process::id()))
}
+4
View File
@@ -8,6 +8,10 @@ license = "Apache-2.0"
name = "dbx-web"
path = "src/main.rs"
[features]
default = ["mq-admin"]
mq-admin = ["dbx-core/mq-admin"]
[dependencies]
dbx-core = { path = "../dbx-core" }
redis = { version = "0.32", features = ["tokio-comp"] }
+53 -1
View File
@@ -32,6 +32,56 @@ fn web_body_limit_bytes() -> usize {
mb.saturating_mul(1024 * 1024)
}
#[cfg(feature = "mq-admin")]
fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
router
.route("/mq/test-connection", post(routes::mq::test_connection))
.route("/mq/tenants/list", post(routes::mq::list_tenants))
.route("/mq/tenants/get", post(routes::mq::get_tenant))
.route("/mq/tenants/create", post(routes::mq::create_tenant))
.route("/mq/tenants/update", post(routes::mq::update_tenant))
.route("/mq/tenants/delete", post(routes::mq::delete_tenant))
.route("/mq/namespaces/list", post(routes::mq::list_namespaces))
.route("/mq/namespaces/create", post(routes::mq::create_namespace))
.route("/mq/namespaces/delete", post(routes::mq::delete_namespace))
.route("/mq/namespaces/policies", post(routes::mq::get_namespace_policies))
.route("/mq/topics/list", post(routes::mq::list_topics))
.route("/mq/topics/create", post(routes::mq::create_topic))
.route("/mq/topics/delete", post(routes::mq::delete_topic))
.route("/mq/topics/update-partitions", post(routes::mq::update_partitions))
.route("/mq/topics/stats", post(routes::mq::get_topic_stats))
.route("/mq/topics/internal-stats", post(routes::mq::get_topic_internal_stats))
.route("/mq/subscriptions/list", post(routes::mq::list_subscriptions))
.route("/mq/subscriptions/create", post(routes::mq::create_subscription))
.route("/mq/subscriptions/delete", post(routes::mq::delete_subscription))
.route("/mq/subscriptions/skip-messages", post(routes::mq::skip_messages))
.route("/mq/subscriptions/reset-cursor", post(routes::mq::reset_cursor))
.route("/mq/subscriptions/clear-backlog", post(routes::mq::clear_backlog))
.route("/mq/subscriptions/peek-messages", post(routes::mq::peek_messages))
.route("/mq/subscriptions/expire-messages", post(routes::mq::expire_messages))
.route("/mq/producers/list", post(routes::mq::list_producers))
.route("/mq/consumers/list", post(routes::mq::list_consumers))
.route("/mq/topics/unload", post(routes::mq::unload_topic))
.route("/mq/policies/publish-rate", post(routes::mq::set_publish_rate))
.route("/mq/policies/dispatch-rate", post(routes::mq::set_dispatch_rate))
.route("/mq/policies/subscribe-rate", post(routes::mq::set_subscribe_rate))
.route("/mq/policies/backlog-quota", post(routes::mq::set_backlog_quota))
.route("/mq/policies/retention", post(routes::mq::set_retention))
.route("/mq/policies/effective", post(routes::mq::get_effective_policies))
.route("/mq/permissions/grant", post(routes::mq::grant_permission))
.route("/mq/permissions/revoke", post(routes::mq::revoke_permission))
.route("/mq/permissions/list", post(routes::mq::list_permissions))
.route("/mq/tokens/issue", post(routes::mq::issue_token))
.route("/mq/tokens/list", post(routes::mq::list_token_records))
.route("/mq/monitoring/backlog", post(routes::mq::get_backlog))
.route("/mq/raw", post(routes::mq::raw_request))
}
#[cfg(not(feature = "mq-admin"))]
fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
router
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
@@ -341,7 +391,9 @@ async fn main() {
"/app-settings/pinned-tree-node-ids",
get(routes::app_settings::load_pinned_tree_node_ids).post(routes::app_settings::save_pinned_tree_node_ids),
)
.route("/app-settings/config/decrypt", post(routes::app_settings::decrypt_config))
.route("/app-settings/config/decrypt", post(routes::app_settings::decrypt_config));
let api = add_mq_routes(api)
.layer(middleware::from_fn_with_state(web_state.clone(), auth::auth_middleware))
.with_state(web_state.clone());
+259 -5
View File
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::sync::Arc;
use axum::extract::State;
@@ -50,6 +51,7 @@ pub async fn test_connection(
// Clean up any pool keys created for the temporary connection, including
// database-scoped keys like "__test_uuid:database".
app.remove_connection_pools(&temp_id).await;
app.reset_connection_transport_for_config(&temp_id, &config).await;
app.configs.write().await.remove(&temp_id);
match result {
@@ -66,6 +68,8 @@ pub async fn connect_db(
let app = &state.app;
let connection_id = config.id.clone();
app.remove_connection_pools(&connection_id).await;
app.reset_connection_transport_for_config(&connection_id, &config).await;
app.configs.write().await.insert(connection_id.clone(), config.clone());
app.get_or_create_pool(&connection_id, None).await.map_err(AppError)?;
@@ -120,26 +124,86 @@ pub async fn save_connections(
Json(body): Json<SaveConnectionsRequest>,
) -> Result<Json<()>, AppError> {
state.app.storage.save_connections(&body.configs).await.map_err(AppError)?;
cache_connection_configs(&state, &body.configs).await;
let sync = sync_connection_configs(&state, &body.configs).await;
remove_connection_pools_for_connection_ids(&state, &sync.connection_pool_ids_to_drop).await;
drop_mq_adapters_for_connection_ids(&state, &sync.mq_adapter_ids_to_drop).await;
Ok(Json(()))
}
pub async fn load_connections(State(state): State<Arc<WebState>>) -> Result<Json<Vec<ConnectionConfig>>, AppError> {
let configs = state.app.storage.load_connections().await.map_err(AppError)?;
cache_connection_configs(&state, &configs).await;
let sync = sync_connection_configs(&state, &configs).await;
remove_connection_pools_for_connection_ids(&state, &sync.connection_pool_ids_to_drop).await;
drop_mq_adapters_for_connection_ids(&state, &sync.mq_adapter_ids_to_drop).await;
Ok(Json(configs))
}
async fn cache_connection_configs(state: &WebState, configs: &[ConnectionConfig]) {
struct ConnectionConfigSync {
mq_adapter_ids_to_drop: Vec<String>,
connection_pool_ids_to_drop: Vec<String>,
}
async fn sync_connection_configs(state: &WebState, configs: &[ConnectionConfig]) -> ConnectionConfigSync {
let saved_ids: HashSet<&str> = configs.iter().map(|config| config.id.as_str()).collect();
let mut mq_adapter_ids_to_drop = HashSet::new();
let mut connection_pool_ids_to_drop = HashSet::new();
let mut runtime_configs = state.app.configs.write().await;
runtime_configs.retain(|id, existing| {
if saved_ids.contains(id.as_str()) || is_transient_runtime_config_id(id) {
true
} else {
connection_pool_ids_to_drop.insert(id.clone());
if existing.db_type == dbx_core::models::connection::DatabaseType::MessageQueue {
mq_adapter_ids_to_drop.insert(id.clone());
}
false
}
});
for config in configs {
runtime_configs.insert(config.id.clone(), config.clone());
if config.db_type == dbx_core::models::connection::DatabaseType::MessageQueue {
mq_adapter_ids_to_drop.insert(config.id.clone());
}
if let Some(previous) = runtime_configs.insert(config.id.clone(), config.clone()) {
if previous.db_type == dbx_core::models::connection::DatabaseType::MessageQueue {
mq_adapter_ids_to_drop.insert(config.id.clone());
}
if &previous != config {
connection_pool_ids_to_drop.insert(config.id.clone());
}
}
}
ConnectionConfigSync {
mq_adapter_ids_to_drop: mq_adapter_ids_to_drop.into_iter().collect(),
connection_pool_ids_to_drop: connection_pool_ids_to_drop.into_iter().collect(),
}
}
fn is_transient_runtime_config_id(id: &str) -> bool {
id.starts_with("__test_") || id.starts_with("__visible_draft_")
}
#[cfg(feature = "mq-admin")]
async fn drop_mq_adapters_for_connection_ids(state: &WebState, connection_ids: &[String]) {
for connection_id in connection_ids {
state.app.mq_registry.drop_connection(connection_id).await;
}
}
#[cfg(not(feature = "mq-admin"))]
async fn drop_mq_adapters_for_connection_ids(_state: &WebState, _connection_ids: &[String]) {}
async fn remove_connection_pools_for_connection_ids(state: &WebState, connection_ids: &[String]) {
for connection_id in connection_ids {
state.app.remove_connection_pools(connection_id).await;
}
}
#[cfg(test)]
mod tests {
use super::{disconnect_db, save_connections, DisconnectRequest, SaveConnectionsRequest};
use super::{
connect_db, disconnect_db, load_connections, save_connections, ConnectRequest, DisconnectRequest,
SaveConnectionsRequest,
};
use crate::state::{LoginRateLimit, WebState};
use axum::extract::State;
use axum::Json;
@@ -148,6 +212,8 @@ mod tests {
use dbx_core::storage::Storage;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::{Mutex, RwLock};
fn sqlite_config(id: &str, path: &str) -> ConnectionConfig {
@@ -196,6 +262,19 @@ mod tests {
}
}
fn mq_config(id: &str, admin_url: &str) -> ConnectionConfig {
let mut config = sqlite_config(id, "");
config.name = "Pulsar".to_string();
config.db_type = DatabaseType::MessageQueue;
config.external_config = Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": admin_url,
"auth": { "kind": "none" },
"pinnedVersion": "3.1"
}));
config
}
async fn test_web_state() -> (Arc<WebState>, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("dbx-web-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
@@ -215,6 +294,34 @@ mod tests {
(state, dir)
}
async fn spawn_pulsar_clusters_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let mut buf = [0_u8; 1024];
let Ok(n) = stream.read(&mut buf).await else {
return;
};
let request = String::from_utf8_lossy(&buf[..n]);
let status =
if request.starts_with("GET /admin/v2/clusters ") { "200 OK" } else { "404 Not Found" };
let body = if status.starts_with("200") { r#"["ec-pulsar"]"# } else { r#"{"reason":"missing"}"# };
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
});
}
});
format!("http://{addr}")
}
#[tokio::test]
async fn save_connections_updates_runtime_config_cache() {
let (state, dir) = test_web_state().await;
@@ -232,6 +339,133 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn save_connections_drops_cached_mq_adapter_for_updated_config() {
let (state, dir) = test_web_state().await;
let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
let first = state.app.mq_registry.get_or_build(&initial).await.unwrap();
let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
let result =
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![updated.clone()] }))
.await;
assert!(result.is_ok());
let cached_admin_url = state
.app
.configs
.read()
.await
.get("mq-conn")
.and_then(|config| config.external_config.as_ref())
.and_then(|external| external.get("adminUrl"))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
assert_eq!(cached_admin_url.as_deref(), Some("http://127.0.0.1:8081"));
let second = state.app.mq_registry.get_or_build(&updated).await.unwrap();
assert!(!Arc::ptr_eq(&first, &second));
assert!(!state.app.connections.read().await.contains_key(&initial.id));
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(feature = "mq-admin")]
#[tokio::test]
async fn connect_db_rebuilds_mq_adapter_for_updated_config_with_same_id() {
let (state, dir) = test_web_state().await;
let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
let first = state.app.mq_registry.get_or_build(&initial).await.unwrap();
let updated = mq_config("mq-conn", &spawn_pulsar_clusters_server().await);
let result = connect_db(State(state.clone()), Json(ConnectRequest { config: updated.clone() })).await;
assert!(result.is_ok());
let second = state.app.mq_registry.get_or_build(&updated).await.unwrap();
assert!(!Arc::ptr_eq(&first, &second));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn load_connections_drops_cached_pool_for_updated_config() {
let (state, dir) = test_web_state().await;
let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
state.app.storage.save_connections(&[updated.clone()]).await.unwrap();
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
let result = load_connections(State(state.clone())).await;
assert!(result.is_ok());
let configs = state.app.configs.read().await;
let cached_admin_url = configs
.get("mq-conn")
.and_then(|config| config.external_config.as_ref())
.and_then(|external| external.get("adminUrl"))
.and_then(serde_json::Value::as_str);
assert_eq!(cached_admin_url, Some("http://127.0.0.1:8081"));
drop(configs);
assert!(!state.app.connections.read().await.contains_key(&initial.id));
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(feature = "mq-admin")]
#[tokio::test]
async fn save_connections_removes_deleted_runtime_config_and_mq_adapter() {
let (state, dir) = test_web_state().await;
let kept = sqlite_config("kept", &dir.join("kept.db").to_string_lossy());
let removed = mq_config("removed-mq", "http://127.0.0.1:8080");
{
let mut configs = state.app.configs.write().await;
configs.insert(kept.id.clone(), kept.clone());
configs.insert(removed.id.clone(), removed.clone());
}
let stale = state.app.mq_registry.get_or_build(&removed).await.unwrap();
let result =
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![kept.clone()] })).await;
assert!(result.is_ok());
let configs = state.app.configs.read().await;
assert!(configs.contains_key("kept"));
assert!(!configs.contains_key("removed-mq"));
drop(configs);
let rebuilt = state.app.mq_registry.get_or_build(&removed).await.unwrap();
assert!(!Arc::ptr_eq(&stale, &rebuilt));
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(feature = "mq-admin")]
#[tokio::test]
async fn save_connections_removes_deleted_connection_pools() {
let (state, dir) = test_web_state().await;
let kept = sqlite_config("kept", &dir.join("kept.db").to_string_lossy());
let removed = mq_config("removed-mq", "http://127.0.0.1:8080");
{
let mut configs = state.app.configs.write().await;
configs.insert(kept.id.clone(), kept.clone());
configs.insert(removed.id.clone(), removed.clone());
}
state.app.connections.write().await.insert(removed.id.clone(), PoolKind::MessageQueue);
let result =
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![kept.clone()] })).await;
assert!(result.is_ok());
assert!(!state.app.connections.read().await.contains_key(&removed.id));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn disconnect_db_keeps_connections_with_similar_prefixes() {
let (state, dir) = test_web_state().await;
@@ -285,6 +519,26 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(feature = "mq-admin")]
#[tokio::test]
async fn disconnect_db_drops_cached_mq_adapter() {
let (state, dir) = test_web_state().await;
let config = mq_config("mq-conn", "http://127.0.0.1:8080");
state.app.configs.write().await.insert(config.id.clone(), config.clone());
state.app.connections.write().await.insert(config.id.clone(), PoolKind::MessageQueue);
let first = state.app.mq_registry.get_or_build(&config).await.unwrap();
let result =
disconnect_db(State(state.clone()), Json(DisconnectRequest { connection_id: config.id.clone() })).await;
assert!(result.is_ok());
assert!(!state.app.connections.read().await.contains_key(&config.id));
let second = state.app.mq_registry.get_or_build(&config).await.unwrap();
assert!(!Arc::ptr_eq(&first, &second));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn disconnect_db_removes_visible_database_draft_config() {
let (state, dir) = test_web_state().await;
+2
View File
@@ -9,6 +9,8 @@ pub mod history;
pub mod jdbc;
pub mod layout;
pub mod mongo;
#[cfg(feature = "mq-admin")]
pub mod mq;
pub mod plugins;
pub mod query;
pub mod redis;
+711
View File
@@ -0,0 +1,711 @@
//! Web (Axum) routes for message queue admin operations.
//!
//! Mirrors the desktop command layer, sharing the same `dbx_core::mq::service::*_core`
//! functions, with read-only protection for mutating operations.
use axum::extract::State;
use axum::Json;
use std::sync::Arc;
use crate::error::AppError;
use crate::state::WebState;
// Request wrappers for endpoints that need more than just connection_id.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ConnReq {
connection_id: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TenantReq {
connection_id: String,
name: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreateTenantReq {
connection_id: String,
name: String,
config: dbx_core::mq::TenantConfig,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UpdateTenantReq {
connection_id: String,
name: String,
config: dbx_core::mq::TenantConfig,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DeleteTenantReq {
connection_id: String,
name: String,
#[serde(default)]
force: bool,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ListNamespacesReq {
connection_id: String,
tenant: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreateNamespaceReq {
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
config: dbx_core::mq::NamespaceConfig,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DeleteNamespaceReq {
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
#[serde(default)]
force: bool,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct NamespacePoliciesReq {
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ListTopicsReq {
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
opts: dbx_core::mq::ListTopicsOpts,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreateTopicReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
partitions: Option<u32>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DeleteTopicReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
#[serde(default)]
force: bool,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UpdatePartitionsReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
partitions: u32,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TopicReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreateSubscriptionReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
sub: String,
pos: dbx_core::mq::ResetPosition,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DeleteSubscriptionReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
sub: String,
#[serde(default)]
force: bool,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SkipMessagesReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
sub: String,
count: dbx_core::mq::SkipCount,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ResetCursorReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
sub: String,
pos: dbx_core::mq::ResetPosition,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SubscriptionReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
sub: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PeekMessagesReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
sub: String,
count: u32,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ExpireMessagesReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
sub: String,
expire_seconds: i64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SetPublishRateReq {
connection_id: String,
scope: dbx_core::mq::PolicyScope,
rate: dbx_core::mq::PublishRate,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SetDispatchRateReq {
connection_id: String,
scope: dbx_core::mq::PolicyScope,
rate: dbx_core::mq::DispatchRate,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SetSubscribeRateReq {
connection_id: String,
scope: dbx_core::mq::PolicyScope,
rate: dbx_core::mq::SubscribeRate,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SetBacklogQuotaReq {
connection_id: String,
scope: dbx_core::mq::PolicyScope,
quota: dbx_core::mq::BacklogQuota,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SetRetentionReq {
connection_id: String,
scope: dbx_core::mq::PolicyScope,
retention: dbx_core::mq::RetentionPolicy,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PolicyScopeReq {
connection_id: String,
scope: dbx_core::mq::PolicyScope,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GrantPermissionReq {
connection_id: String,
scope: dbx_core::mq::PolicyScope,
role: String,
actions: Vec<dbx_core::mq::AuthAction>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RevokePermissionReq {
connection_id: String,
scope: dbx_core::mq::PolicyScope,
role: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct IssueTokenReq {
connection_id: String,
req: dbx_core::mq::MqTokenIssueRequest,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ListTokenRecordsReq {
connection_id: String,
subject: Option<String>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BacklogReq {
connection_id: String,
topic: dbx_core::mq::TopicRef,
sub: Option<String>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RawRequestReq {
connection_id: String,
req: dbx_core::mq::MqRawRequest,
}
// ---- Helper: writable check ----
async fn ensure_writable(app: &dbx_core::connection::AppState, conn_id: &str, action: &str) -> Result<(), AppError> {
if let Some(name) = dbx_core::query::connection_readonly_name(app, conn_id).await {
return Err(AppError(format!("Read-only connection '{name}'. {action} is blocked.")));
}
Ok(())
}
// ---- Handlers ----
pub async fn test_connection(
State(state): State<Arc<WebState>>,
Json(req): Json<ConnReq>,
) -> Result<Json<dbx_core::mq::MqClusterInfo>, AppError> {
let result =
dbx_core::mq::service::mq_test_connection_core(&state.app, &req.connection_id).await.map_err(AppError)?;
Ok(Json(result))
}
pub async fn list_tenants(
State(state): State<Arc<WebState>>,
Json(req): Json<ConnReq>,
) -> Result<Json<Vec<dbx_core::mq::TenantInfo>>, AppError> {
let result = dbx_core::mq::service::mq_list_tenants_core(&state.app, &req.connection_id).await.map_err(AppError)?;
Ok(Json(result))
}
pub async fn get_tenant(
State(state): State<Arc<WebState>>,
Json(req): Json<TenantReq>,
) -> Result<Json<dbx_core::mq::TenantInfo>, AppError> {
let result =
dbx_core::mq::service::mq_get_tenant_core(&state.app, &req.connection_id, &req.name).await.map_err(AppError)?;
Ok(Json(result))
}
pub async fn create_tenant(
State(state): State<Arc<WebState>>,
Json(req): Json<CreateTenantReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Create tenant").await?;
dbx_core::mq::service::mq_create_tenant_core(&state.app, &req.connection_id, &req.name, req.config)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn update_tenant(
State(state): State<Arc<WebState>>,
Json(req): Json<UpdateTenantReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Update tenant").await?;
dbx_core::mq::service::mq_update_tenant_core(&state.app, &req.connection_id, &req.name, req.config)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn delete_tenant(
State(state): State<Arc<WebState>>,
Json(req): Json<DeleteTenantReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Delete tenant").await?;
dbx_core::mq::service::mq_delete_tenant_core(&state.app, &req.connection_id, &req.name, req.force)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn list_namespaces(
State(state): State<Arc<WebState>>,
Json(req): Json<ListNamespacesReq>,
) -> Result<Json<Vec<dbx_core::mq::NamespaceInfo>>, AppError> {
let result = dbx_core::mq::service::mq_list_namespaces_core(&state.app, &req.connection_id, &req.tenant)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn create_namespace(
State(state): State<Arc<WebState>>,
Json(req): Json<CreateNamespaceReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Create namespace").await?;
dbx_core::mq::service::mq_create_namespace_core(&state.app, &req.connection_id, req.ns, req.config)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn delete_namespace(
State(state): State<Arc<WebState>>,
Json(req): Json<DeleteNamespaceReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Delete namespace").await?;
dbx_core::mq::service::mq_delete_namespace_core(&state.app, &req.connection_id, req.ns, req.force)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn get_namespace_policies(
State(state): State<Arc<WebState>>,
Json(req): Json<NamespacePoliciesReq>,
) -> Result<Json<serde_json::Value>, AppError> {
let result = dbx_core::mq::service::mq_get_namespace_policies_core(&state.app, &req.connection_id, req.ns)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn list_topics(
State(state): State<Arc<WebState>>,
Json(req): Json<ListTopicsReq>,
) -> Result<Json<Vec<dbx_core::mq::TopicInfo>>, AppError> {
let result = dbx_core::mq::service::mq_list_topics_core(&state.app, &req.connection_id, req.ns, req.opts)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn create_topic(
State(state): State<Arc<WebState>>,
Json(req): Json<CreateTopicReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Create topic").await?;
dbx_core::mq::service::mq_create_topic_core(&state.app, &req.connection_id, req.topic, req.partitions)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn delete_topic(
State(state): State<Arc<WebState>>,
Json(req): Json<DeleteTopicReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Delete topic").await?;
dbx_core::mq::service::mq_delete_topic_core(&state.app, &req.connection_id, req.topic, req.force)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn update_partitions(
State(state): State<Arc<WebState>>,
Json(req): Json<UpdatePartitionsReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Update partitions").await?;
dbx_core::mq::service::mq_update_partitions_core(&state.app, &req.connection_id, req.topic, req.partitions)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn get_topic_stats(
State(state): State<Arc<WebState>>,
Json(req): Json<TopicReq>,
) -> Result<Json<dbx_core::mq::TopicStats>, AppError> {
let result = dbx_core::mq::service::mq_get_topic_stats_core(&state.app, &req.connection_id, req.topic)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn get_topic_internal_stats(
State(state): State<Arc<WebState>>,
Json(req): Json<TopicReq>,
) -> Result<Json<serde_json::Value>, AppError> {
let result = dbx_core::mq::service::mq_get_topic_internal_stats_core(&state.app, &req.connection_id, req.topic)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn list_subscriptions(
State(state): State<Arc<WebState>>,
Json(req): Json<TopicReq>,
) -> Result<Json<Vec<dbx_core::mq::SubscriptionInfo>>, AppError> {
let result = dbx_core::mq::service::mq_list_subscriptions_core(&state.app, &req.connection_id, req.topic)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn create_subscription(
State(state): State<Arc<WebState>>,
Json(req): Json<CreateSubscriptionReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Create subscription").await?;
dbx_core::mq::service::mq_create_subscription_core(&state.app, &req.connection_id, req.topic, req.sub, req.pos)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn delete_subscription(
State(state): State<Arc<WebState>>,
Json(req): Json<DeleteSubscriptionReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Delete subscription").await?;
dbx_core::mq::service::mq_delete_subscription_core(&state.app, &req.connection_id, req.topic, req.sub, req.force)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn skip_messages(
State(state): State<Arc<WebState>>,
Json(req): Json<SkipMessagesReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Skip messages").await?;
dbx_core::mq::service::mq_skip_messages_core(&state.app, &req.connection_id, req.topic, req.sub, req.count)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn reset_cursor(
State(state): State<Arc<WebState>>,
Json(req): Json<ResetCursorReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Reset cursor").await?;
dbx_core::mq::service::mq_reset_cursor_core(&state.app, &req.connection_id, req.topic, req.sub, req.pos)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn clear_backlog(
State(state): State<Arc<WebState>>,
Json(req): Json<SubscriptionReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Clear backlog").await?;
dbx_core::mq::service::mq_clear_backlog_core(&state.app, &req.connection_id, req.topic, req.sub)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn peek_messages(
State(state): State<Arc<WebState>>,
Json(req): Json<PeekMessagesReq>,
) -> Result<Json<Vec<dbx_core::mq::PeekedMessage>>, AppError> {
let result =
dbx_core::mq::service::mq_peek_messages_core(&state.app, &req.connection_id, req.topic, req.sub, req.count)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn expire_messages(
State(state): State<Arc<WebState>>,
Json(req): Json<ExpireMessagesReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Expire messages").await?;
dbx_core::mq::service::mq_expire_messages_core(
&state.app,
&req.connection_id,
req.topic,
req.sub,
req.expire_seconds,
)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn list_producers(
State(state): State<Arc<WebState>>,
Json(req): Json<TopicReq>,
) -> Result<Json<Vec<dbx_core::mq::ProducerInfo>>, AppError> {
let result = dbx_core::mq::service::mq_list_producers_core(&state.app, &req.connection_id, req.topic)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn list_consumers(
State(state): State<Arc<WebState>>,
Json(req): Json<SubscriptionReq>,
) -> Result<Json<Vec<dbx_core::mq::ConsumerInfo>>, AppError> {
let result = dbx_core::mq::service::mq_list_consumers_core(&state.app, &req.connection_id, req.topic, req.sub)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn unload_topic(State(state): State<Arc<WebState>>, Json(req): Json<TopicReq>) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Unload topic").await?;
dbx_core::mq::service::mq_unload_topic_core(&state.app, &req.connection_id, req.topic).await.map_err(AppError)?;
Ok(Json(()))
}
pub async fn set_publish_rate(
State(state): State<Arc<WebState>>,
Json(req): Json<SetPublishRateReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Set publish rate").await?;
dbx_core::mq::service::mq_set_publish_rate_core(&state.app, &req.connection_id, req.scope, req.rate)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn set_dispatch_rate(
State(state): State<Arc<WebState>>,
Json(req): Json<SetDispatchRateReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Set dispatch rate").await?;
dbx_core::mq::service::mq_set_dispatch_rate_core(&state.app, &req.connection_id, req.scope, req.rate)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn set_subscribe_rate(
State(state): State<Arc<WebState>>,
Json(req): Json<SetSubscribeRateReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Set subscribe rate").await?;
dbx_core::mq::service::mq_set_subscribe_rate_core(&state.app, &req.connection_id, req.scope, req.rate)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn set_backlog_quota(
State(state): State<Arc<WebState>>,
Json(req): Json<SetBacklogQuotaReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Set backlog quota").await?;
dbx_core::mq::service::mq_set_backlog_quota_core(&state.app, &req.connection_id, req.scope, req.quota)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn set_retention(
State(state): State<Arc<WebState>>,
Json(req): Json<SetRetentionReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Set retention").await?;
dbx_core::mq::service::mq_set_retention_core(&state.app, &req.connection_id, req.scope, req.retention)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn get_effective_policies(
State(state): State<Arc<WebState>>,
Json(req): Json<PolicyScopeReq>,
) -> Result<Json<serde_json::Value>, AppError> {
let result = dbx_core::mq::service::mq_get_effective_policies_core(&state.app, &req.connection_id, req.scope)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn grant_permission(
State(state): State<Arc<WebState>>,
Json(req): Json<GrantPermissionReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Grant permission").await?;
dbx_core::mq::service::mq_grant_permission_core(&state.app, &req.connection_id, req.scope, req.role, req.actions)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn revoke_permission(
State(state): State<Arc<WebState>>,
Json(req): Json<RevokePermissionReq>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Revoke permission").await?;
dbx_core::mq::service::mq_revoke_permission_core(&state.app, &req.connection_id, req.scope, req.role)
.await
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn list_permissions(
State(state): State<Arc<WebState>>,
Json(req): Json<PolicyScopeReq>,
) -> Result<Json<dbx_core::mq::PermissionMap>, AppError> {
let result = dbx_core::mq::service::mq_list_permissions_core(&state.app, &req.connection_id, req.scope)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn issue_token(
State(state): State<Arc<WebState>>,
Json(req): Json<IssueTokenReq>,
) -> Result<Json<dbx_core::mq::MqIssuedToken>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Issue MQ token").await?;
let result =
dbx_core::mq::service::mq_issue_token_core(&state.app, &req.connection_id, req.req).await.map_err(AppError)?;
Ok(Json(result))
}
pub async fn list_token_records(
State(state): State<Arc<WebState>>,
Json(req): Json<ListTokenRecordsReq>,
) -> Result<Json<Vec<dbx_core::mq::MqTokenRecord>>, AppError> {
let result = dbx_core::mq::service::mq_list_token_records_core(&state.app, &req.connection_id, req.subject)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn get_backlog(
State(state): State<Arc<WebState>>,
Json(req): Json<BacklogReq>,
) -> Result<Json<dbx_core::mq::BacklogStats>, AppError> {
let result = dbx_core::mq::service::mq_get_backlog_core(&state.app, &req.connection_id, req.topic, req.sub)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn raw_request(
State(state): State<Arc<WebState>>,
Json(req): Json<RawRequestReq>,
) -> Result<Json<dbx_core::mq::MqRawResponse>, AppError> {
if req.req.is_mutating() {
ensure_writable(&state.app, &req.connection_id, "MQ admin write").await?;
}
let result =
dbx_core::mq::service::mq_raw_request_core(&state.app, &req.connection_id, req.req).await.map_err(AppError)?;
Ok(Json(result))
}