diff --git a/docs/guide/messaging/transports/rabbitmq/object-management.md b/docs/guide/messaging/transports/rabbitmq/object-management.md
index 9d0c1b143..1fcf7b3d8 100644
--- a/docs/guide/messaging/transports/rabbitmq/object-management.md
+++ b/docs/guide/messaging/transports/rabbitmq/object-management.md
@@ -143,6 +143,51 @@ return await app.RunJasperFxCommands(args);
Note that this stateful resource model is also available at the command line as well for deploy time
management.
+## Externally-Owned Queues and Exchanges
+
+Sometimes a queue or exchange your application uses is owned and managed by a *different* system,
+and the identity your application connects with simply does not have the `configure` or `delete`
+permissions to create or remove it. In that case you want Wolverine to *use* the queue or exchange,
+but never try to declare it at startup (even with `AutoProvision()` turned on) and never delete it
+during a resource teardown. Mark the endpoint as `ExternallyOwned()` to get exactly that behavior:
+
+```csharp
+using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.UseRabbitMq()
+ // AutoProvision is on for the resources this app *does* own...
+ .AutoProvision();
+
+ // ...but this queue belongs to another team. Listen to it,
+ // but never declare it at startup or delete it on teardown,
+ // and don't try to set up or tear down its bindings.
+ opts.ListenToRabbitQueue("shared-orders")
+ .ExternallyOwned();
+
+ // Same idea for an exchange we publish to but do not own
+ opts.PublishMessage()
+ .ToRabbitExchange("shared-events")
+ .ExternallyOwned();
+ }).StartAsync();
+```
+
+When an endpoint is marked `ExternallyOwned()`, Wolverine will:
+
+* Skip declaring the queue or exchange at startup, regardless of `AutoProvision()`
+* Skip deleting it during a `resources teardown` (or the Oakton/JasperFx resource commands)
+* Skip setting up or tearing down any bindings for that resource
+
+This applies to queue listeners, queue subscribers, and exchange subscribers alike.
+
+::: tip
+`ExternallyOwned()` is distinct from `DeclarePassive`. A `DeclarePassive` exchange still makes a
+*passive* declaration against the broker at startup to verify that the resource already exists
+(failing fast if it does not), whereas an externally-owned resource never touches the broker for
+declaration at all. As of Wolverine 6.6, a `DeclarePassive` exchange is also left alone during
+resource teardown — Wolverine will not delete a resource it only verified rather than created.
+:::
+
## Exchange-to-Exchange Bindings
Wolverine supports [RabbitMQ exchange-to-exchange bindings](https://www.rabbitmq.com/docs/e2e), which allow you
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ.Tests/externally_owned_rabbit_topology_is_skipped.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ.Tests/externally_owned_rabbit_topology_is_skipped.cs
new file mode 100644
index 000000000..de85c7408
--- /dev/null
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ.Tests/externally_owned_rabbit_topology_is_skipped.cs
@@ -0,0 +1,175 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using RabbitMQ.Client;
+using Shouldly;
+using Wolverine.ComplianceTests;
+using Wolverine.RabbitMQ.Internal;
+using Xunit;
+
+namespace Wolverine.RabbitMQ.Tests;
+
+// GH-3064: a queue or exchange marked ExternallyOwned() must never be declared (created) at startup
+// nor deleted during resource teardown, even with AutoProvision on - the escape hatch for topology
+// owned by another system where the calling identity lacks configure/delete permissions. Assertions
+// hit the broker directly (passive declare on a separate admin connection) rather than relying on
+// Wolverine's in-memory model.
+public class externally_owned_rabbit_topology_is_skipped : IAsyncLifetime
+{
+ private readonly string _externalQueue = "ext-queue-" + Guid.NewGuid().ToString("N");
+ private readonly string _externalExchange = "ext-exchange-" + Guid.NewGuid().ToString("N");
+ private readonly string _ownedQueue = "owned-queue-" + Guid.NewGuid().ToString("N");
+ private readonly string _passiveExchange = "passive-exchange-" + Guid.NewGuid().ToString("N");
+
+ public Task InitializeAsync() => Task.CompletedTask;
+
+ public async Task DisposeAsync()
+ {
+ try
+ {
+ await using var conn = await new ConnectionFactory { HostName = "localhost" }.CreateConnectionAsync();
+ await using var channel = await conn.CreateChannelAsync();
+ foreach (var q in new[] { _externalQueue, _ownedQueue })
+ {
+ try { await channel.QueueDeleteAsync(q, false, false); } catch { }
+ }
+ foreach (var e in new[] { _externalExchange, _passiveExchange })
+ {
+ try { await channel.ExchangeDeleteAsync(e); } catch { }
+ }
+ }
+ catch
+ {
+ // ignore cleanup failures
+ }
+ }
+
+ // --- broker-side existence checks via a separate admin connection ---
+
+ private static async Task QueueExistsAsync(string name)
+ {
+ await using var conn = await new ConnectionFactory { HostName = "localhost" }.CreateConnectionAsync();
+ await using var channel = await conn.CreateChannelAsync();
+ try { await channel.QueueDeclarePassiveAsync(name); return true; }
+ catch { return false; } // passive declare on a missing queue closes the channel with a 404
+ }
+
+ private static async Task ExchangeExistsAsync(string name)
+ {
+ await using var conn = await new ConnectionFactory { HostName = "localhost" }.CreateConnectionAsync();
+ await using var channel = await conn.CreateChannelAsync();
+ try { await channel.ExchangeDeclarePassiveAsync(name); return true; }
+ catch { return false; }
+ }
+
+ private static async Task PreCreateQueueAsync(string name)
+ {
+ await using var conn = await new ConnectionFactory { HostName = "localhost" }.CreateConnectionAsync();
+ await using var channel = await conn.CreateChannelAsync();
+ await channel.QueueDeclareAsync(name, durable: true, exclusive: false, autoDelete: false);
+ }
+
+ private static async Task PreCreateExchangeAsync(string name)
+ {
+ await using var conn = await new ConnectionFactory { HostName = "localhost" }.CreateConnectionAsync();
+ await using var channel = await conn.CreateChannelAsync();
+ await channel.ExchangeDeclareAsync(name, type: "fanout", durable: true, autoDelete: false);
+ }
+
+ // === Setup: externally-owned topology is never created ===
+
+ [Fact]
+ public async Task externally_owned_queue_is_not_created_at_startup()
+ {
+ using var host = await WolverineHost.ForAsync(opts =>
+ {
+ opts.UseRabbitMq().AutoProvision();
+ opts.PublishMessage().ToRabbitQueue(_externalQueue).ExternallyOwned();
+ });
+
+ (await QueueExistsAsync(_externalQueue)).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task externally_owned_exchange_is_not_created_at_startup()
+ {
+ using var host = await WolverineHost.ForAsync(opts =>
+ {
+ opts.UseRabbitMq().AutoProvision();
+ opts.PublishMessage().ToRabbitExchange(_externalExchange).ExternallyOwned();
+ });
+
+ (await ExchangeExistsAsync(_externalExchange)).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task owned_topology_is_still_created_alongside_externally_owned()
+ {
+ using var host = await WolverineHost.ForAsync(opts =>
+ {
+ opts.UseRabbitMq().AutoProvision();
+ opts.PublishMessage().ToRabbitQueue(_externalQueue).ExternallyOwned();
+ opts.PublishMessage().ToRabbitQueue(_ownedQueue); // owned -> should be created
+ });
+
+ (await QueueExistsAsync(_ownedQueue)).ShouldBeTrue();
+ (await QueueExistsAsync(_externalQueue)).ShouldBeFalse();
+ }
+
+ // === Teardown: externally-owned topology survives ===
+
+ [Fact]
+ public async Task teardown_leaves_an_externally_owned_listener_queue_alone()
+ {
+ await PreCreateQueueAsync(_externalQueue);
+
+ using var host = await WolverineHost.ForAsync(opts =>
+ {
+ opts.UseRabbitMq();
+ opts.ListenToRabbitQueue(_externalQueue).ExternallyOwned();
+ });
+
+ var queue = host.Get().RabbitMqTransport().Queues[_externalQueue];
+ await queue.TeardownAsync(NullLogger.Instance);
+
+ (await QueueExistsAsync(_externalQueue)).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task teardown_leaves_an_externally_owned_exchange_alone()
+ {
+ await PreCreateExchangeAsync(_externalExchange);
+
+ using var host = await WolverineHost.ForAsync(opts =>
+ {
+ opts.UseRabbitMq();
+ opts.PublishMessage().ToRabbitExchange(_externalExchange).ExternallyOwned();
+ });
+
+ var exchange = host.Get().RabbitMqTransport().Exchanges[_externalExchange];
+ await exchange.TeardownAsync(NullLogger.Instance);
+
+ (await ExchangeExistsAsync(_externalExchange)).ShouldBeTrue();
+ }
+
+ // === Q3: DeclarePassive exchanges must also survive teardown (no longer deleted) ===
+
+ [Fact]
+ public async Task teardown_leaves_a_declare_passive_exchange_alone()
+ {
+ await PreCreateExchangeAsync(_passiveExchange);
+
+ using var host = await WolverineHost.ForAsync(opts =>
+ {
+ opts.PublishMessage().ToRabbitExchange(_passiveExchange, ex => ex.DeclarePassive = true);
+ });
+
+ var exchange = host.Get().RabbitMqTransport().Exchanges[_passiveExchange];
+ await exchange.TeardownAsync(NullLogger.Instance);
+
+ (await ExchangeExistsAsync(_passiveExchange)).ShouldBeTrue();
+ }
+}
+
+public record ExtMessage(string Name);
+
+public record OwnedMessage(string Name);
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqEndpoint.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqEndpoint.cs
index 2e4a0e911..a4653fdaa 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqEndpoint.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqEndpoint.cs
@@ -23,6 +23,16 @@ internal RabbitMqEndpoint(Uri uri, EndpointRole role, RabbitMqTransport parent)
public string ExchangeName { get; protected set; } = string.Empty;
+ ///
+ /// When true, Wolverine treats this queue or exchange as owned by an external system: it
+ /// will not declare (create) it during startup or delete it during resources teardown, even
+ /// when AutoProvision() is enabled on the parent transport. Bindings owned by an
+ /// externally-owned queue/exchange are likewise left untouched. Use this when the calling identity
+ /// lacks the configure/delete permissions for the resource. Default is false.
+ /// See https://github.com/JasperFx/wolverine/issues/3064.
+ ///
+ public bool IsExternallyOwned { get; set; }
+
public abstract ValueTask CheckAsync();
public abstract ValueTask TeardownAsync(ILogger logger);
public abstract ValueTask SetupAsync(ILogger logger);
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqExchange.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqExchange.cs
index b6e727578..e0b254e50 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqExchange.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqExchange.cs
@@ -122,7 +122,7 @@ public override async ValueTask InitializeAsync(ILogger logger)
return;
}
- if (_parent.AutoProvision && !DisableAutoProvision)
+ if (_parent.AutoProvision && !DisableAutoProvision && !IsExternallyOwned)
{
await _parent.WithAdminChannelAsync(model => DeclareAsync(model, logger));
}
@@ -142,7 +142,9 @@ internal override string RoutingKey()
internal async Task DeclareAsync(IChannel channel, ILogger logger)
{
- if (DeclaredName == string.Empty)
+ // Externally-owned exchanges are declared/managed by another system; don't touch the broker
+ // here at all (neither the exchange nor its source-exchange bindings below). GH-3064.
+ if (DeclaredName == string.Empty || IsExternallyOwned)
{
return;
}
@@ -195,6 +197,15 @@ public override async ValueTask CheckAsync()
public override async ValueTask TeardownAsync(ILogger logger)
{
+ // Don't delete an exchange we don't own. IsExternallyOwned is the explicit flag for that;
+ // DeclarePassive is honored here too — it means "only verify existence, never create" at setup,
+ // so deleting on teardown would be asymmetric and would destroy a resource the caller chose not
+ // to create. Bindings are likewise left alone. GH-3064 (DeclarePassive teardown fix included).
+ if (IsExternallyOwned || DeclarePassive)
+ {
+ return;
+ }
+
await _parent.WithAdminChannelAsync(async channel =>
{
foreach (var binding in _exchangeBindings)
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqQueue.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqQueue.cs
index 7bf9ee3da..cdfa876aa 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqQueue.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqQueue.cs
@@ -112,8 +112,9 @@ public override async ValueTask CheckAsync()
public override async ValueTask TeardownAsync(ILogger logger)
{
- // This is a reply uri owned by another node, so get out of here
- if (isSystemQueue() || AutoDelete)
+ // This is a reply uri owned by another node, so get out of here. Externally-owned queues
+ // belong to another system and must not be deleted (nor their bindings torn down). GH-3064.
+ if (isSystemQueue() || AutoDelete || IsExternallyOwned)
{
return;
}
@@ -133,7 +134,8 @@ await _parent.WithAdminChannelAsync(async channel =>
public override async ValueTask SetupAsync(ILogger logger)
{
- if (isSystemQueue())
+ // Externally-owned queues are declared/managed by another system; don't try to create them. GH-3064.
+ if (isSystemQueue() || IsExternallyOwned)
{
return;
}
@@ -270,7 +272,9 @@ internal async ValueTask InitializeAsync(IChannel channel, ILogger logger)
if (_parent.AutoProvision || _parent.AutoPurgeAllQueues || PurgeOnStartup)
{
- if (_parent.AutoProvision)
+ // Externally-owned queues (and their bindings) are managed by another system; skip the
+ // declare even when AutoProvision is on so startup doesn't fail without configure ACLs. GH-3064.
+ if (_parent.AutoProvision && !IsExternallyOwned)
{
await DeclareAsync(channel, logger);
}
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqExchangeConfigurationExpression.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqExchangeConfigurationExpression.cs
index 250b9b39f..5fb7de9e3 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqExchangeConfigurationExpression.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqExchangeConfigurationExpression.cs
@@ -33,6 +33,21 @@ public bool DeclarePassive
get => _exchange.DeclarePassive;
set => _exchange.DeclarePassive = value;
}
+
+ ///
+ /// When true, Wolverine treats this exchange as owned by an external system: it will not
+ /// declare (create) it at startup or delete it during resources teardown, even when
+ /// AutoProvision() is enabled, and will not set up or tear down its bindings. Use this when
+ /// the calling identity lacks the configure/delete permissions for the exchange.
+ /// Distinct from , which still touches the broker to verify existence
+ /// at startup. See https://github.com/JasperFx/wolverine/issues/3064.
+ ///
+ public bool IsExternallyOwned
+ {
+ get => _exchange.IsExternallyOwned;
+ set => _exchange.IsExternallyOwned = value;
+ }
+
public IDictionary Arguments => _exchange.Arguments;
public TopicBindingExchange BindTopic(string topicPattern)
{
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqListenerConfiguration.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqListenerConfiguration.cs
index c19ed715f..83996e44b 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqListenerConfiguration.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqListenerConfiguration.cs
@@ -112,6 +112,18 @@ public RabbitMqListenerConfiguration PreFetchCount(ushort count)
return this;
}
+ ///
+ /// Mark this queue as owned by an external system. Wolverine will not declare (create) the queue
+ /// at startup or delete it during resources teardown, even when AutoProvision() is
+ /// enabled, and will not set up or tear down its bindings. Use this when the calling identity lacks
+ /// the configure/delete permissions for the queue. See https://github.com/JasperFx/wolverine/issues/3064.
+ ///
+ public RabbitMqListenerConfiguration ExternallyOwned()
+ {
+ add(e => e.IsExternallyOwned = true);
+ return this;
+ }
+
///
/// Use a custom interoperability strategy to map Wolverine messages to an upstream
/// system's protocol
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqSubscriberConfiguration.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqSubscriberConfiguration.cs
index fe7152875..0096ae403 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqSubscriberConfiguration.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqSubscriberConfiguration.cs
@@ -72,6 +72,19 @@ public RabbitMqSubscriberConfiguration DisableDeadLetterQueueing()
return this;
}
+
+ ///
+ /// Mark the target queue or exchange as owned by an external system. Wolverine will not declare
+ /// (create) it at startup or delete it during resources teardown, even when
+ /// AutoProvision() is enabled, and will not set up or tear down its bindings. Use this when
+ /// the calling identity lacks the configure/delete permissions for the resource.
+ /// See https://github.com/JasperFx/wolverine/issues/3064.
+ ///
+ public RabbitMqSubscriberConfiguration ExternallyOwned()
+ {
+ add(e => e.IsExternallyOwned = true);
+ return this;
+ }
}
public class RabbitMqExchangeConfiguration : InteroperableSubscriberConfiguration
@@ -101,6 +114,23 @@ public RabbitMqExchangeConfiguration UseNServiceBusInterop()
return this;
}
+ ///
+ /// Modify the exchange type, the default is fan out
+ ///
+ ///
+ ///
+ ///
+ /// Mark this exchange as owned by an external system. Wolverine will not declare (create) it at
+ /// startup or delete it during resources teardown, even when AutoProvision() is
+ /// enabled, and will not set up or tear down its bindings. Use this when the calling identity lacks
+ /// the configure/delete permissions for the exchange. See https://github.com/JasperFx/wolverine/issues/3064.
+ ///
+ public RabbitMqExchangeConfiguration ExternallyOwned()
+ {
+ add(e => e.IsExternallyOwned = true);
+ return this;
+ }
+
///
/// Modify the exchange type, the default is fan out
///