diff --git a/.arclint b/.arclint
--- a/.arclint
+++ b/.arclint
@@ -47,6 +47,7 @@
             ],
             "include": [
                 "(\\.py$)",
+                "(^roles/saas-rabbitmq/server/content.sls$)",
                 "(^roles/viperserv/eggdrop/cron.sls$)",
                 "(^roles/webserver-legacy/php-builder/source.sls$)",
                 "(^roles/webserver-legacy/php-sites/cleanup.sls$)"
diff --git a/_modules/rabbitmq.py b/_modules/rabbitmq.py
deleted file mode 100644
--- a/_modules/rabbitmq.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# -*- coding: utf-8 -*-
-
-#   -------------------------------------------------------------
-#   Salt — RabbitMQ execution module
-#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-#   Project:        Nasqueron
-#   Description:    Allow to use RabbitMQ management plugin HTTP API
-#   License:        BSD-2-Clause
-#   -------------------------------------------------------------
-
-
-import base64
-import hashlib
-import secrets
-
-
-#   -------------------------------------------------------------
-#   Credentials
-#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-def compute_password_hash(password):
-    salt = secrets.randbits(32)
-    return _compute_password_hash_with_salt(salt, password)
-
-
-def _compute_password_hash_with_salt(salt, password):
-    """Reference: https://rabbitmq.com/passwords.html#computing-password-hash"""
-    salt = salt.to_bytes(4, "big")  # salt is a 32 bits (4 bytes) value
-
-    m = hashlib.sha256()
-    m.update(salt)
-    m.update(password.encode("utf-8"))
-    result = salt + m.digest()
-
-    return base64.b64encode(result).decode("utf-8")
diff --git a/_modules/rabbitmq_api.py b/_modules/rabbitmq_api.py
new file mode 100644
--- /dev/null
+++ b/_modules/rabbitmq_api.py
@@ -0,0 +1,327 @@
+#!/usr/bin/env python3
+
+#   -------------------------------------------------------------
+#   Salt - RabbitMQ management HTTP API client
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+#   Project:        Nasqueron
+#   Description:    Connect to RabbitMQ management HTTP API
+#   License:        BSD-2-Clause
+#   -------------------------------------------------------------
+
+
+import base64
+import hashlib
+import json
+import logging
+import secrets
+
+import requests
+from requests.auth import HTTPBasicAuth
+
+
+log = logging.getLogger(__name__)
+
+
+HTTP_SUCCESS_CODES = [200, 201, 204]
+HTTP_CONTENT_CODES = [200]
+
+
+#   -------------------------------------------------------------
+#   RabbitMQ management HTTP API client
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def _request(cluster, method, path, data=None):
+    args = __opts__["rabbitmq"][cluster]
+
+    url = args["url"] + "/" + path
+
+    if args["auth"] == "basic":
+        auth = HTTPBasicAuth(args["user"], args["password"])
+    else:
+        raise RuntimeError(
+            f"RabbitMQ HTTP API authentication scheme not supported: {args['auth']}"
+        )
+
+    headers = {
+        "User-agent": "Salt-RabbitMQ/1.0",
+    }
+
+    if data is not None:
+        data = json.dumps(data)
+
+    log.debug(f"HTTP request {method} to {url}")
+    log.trace(f"Payload: {data}")
+    r = requests.request(method, url, headers=headers, auth=auth, data=data)
+
+    if r.status_code not in HTTP_SUCCESS_CODES:
+        log.error(f"HTTP status code {r.status_code}, 2xx expected.")
+        raise RuntimeError(f"Status code is {r.status_code}")
+
+    if r.status_code not in HTTP_CONTENT_CODES:
+        log.trace(
+            f"HTTP response is {r.status_code}. The API doesn't include any content for this code."
+        )
+        return True
+
+    return r.json()
+
+
+#   -------------------------------------------------------------
+#   Execution module methods
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+ARGS_USER = ["password_hash", "tags"]
+ARGS_VHOST = ["description", "tags", "tracing"]
+ARGS_EXCHANGE = ["type", "auto_delete", "durable", "internal", "arguments"]
+ARGS_QUEUE = ["auto_delete", "durable", "arguments", "node"]
+ARGS_BINDING = ["routing_key", "arguments"]
+
+
+def overview(cluster):
+    return _request(cluster, "GET", "overview")
+
+
+def list_users(cluster):
+    return _request(cluster, "GET", "users")
+
+
+def get_user(cluster, user):
+    user = requests.utils.quote(user, safe="")
+    return _request(cluster, "GET", f"users/{user}")
+
+
+def update_user(cluster, user, **kwargs):
+    user = requests.utils.quote(user, safe="")
+    data = {}
+    for arg in ARGS_USER:
+        if arg in kwargs:
+            data[arg] = kwargs[arg]
+
+    if "password" in kwargs:
+        if "password_hash" in kwargs:
+            raise RuntimeError(
+                "You can't specify both password and password_hash option."
+            )
+        data["password_hash"] = compute_password_hash(kwargs["password"])
+
+    return _request(cluster, "PUT", f"users/{user}", data)
+
+
+def delete_user(cluster, user):
+    user = requests.utils.quote(user, safe="")
+    return _request(cluster, "DELETE", f"users/{user}")
+
+
+def user_exists(cluster, user):
+    return user in [result["name"] for result in list_users(cluster)]
+
+
+def list_vhosts(cluster):
+    return _request(cluster, "GET", "vhosts")
+
+
+def get_vhost(cluster, vhost):
+    vhost = requests.utils.quote(vhost, safe="")
+    return _request(cluster, "GET", f"vhosts/{vhost}")
+
+
+def update_vhost(cluster, vhost, **kwargs):
+    vhost = requests.utils.quote(vhost, safe="")
+    data = {}
+    for arg in ARGS_VHOST:
+        if arg in kwargs:
+            data[arg] = kwargs[arg]
+
+    return _request(cluster, "PUT", f"vhosts/{vhost}", data)
+
+
+def delete_vhost(cluster, vhost):
+    vhost = requests.utils.quote(vhost, safe="")
+    return _request(cluster, "DELETE", f"vhosts/{vhost}")
+
+
+def vhost_exists(cluster, vhost):
+    return vhost in [result["name"] for result in list_vhosts(cluster)]
+
+
+def list_exchanges(cluster, vhost):
+    vhost = requests.utils.quote(vhost, safe="")
+    return _request(cluster, "GET", f"exchanges/{vhost}")
+
+
+def get_exchange(cluster, vhost, exchange):
+    vhost = requests.utils.quote(vhost, safe="")
+    exchange = requests.utils.quote(exchange, safe="")
+    return _request(cluster, "GET", f"exchanges/{vhost}/{exchange}")
+
+
+def update_exchange(cluster, vhost, exchange, **kwargs):
+    vhost = requests.utils.quote(vhost, safe="")
+    exchange = requests.utils.quote(exchange, safe="")
+    data = {}
+    for arg in ARGS_EXCHANGE:
+        if arg in kwargs:
+            data[arg] = kwargs[arg]
+
+    return _request(cluster, "PUT", f"exchanges/{vhost}/{exchange}", data)
+
+
+def delete_exchange(cluster, vhost, exchange):
+    vhost = requests.utils.quote(vhost, safe="")
+    exchange = requests.utils.quote(exchange, safe="")
+    return _request(cluster, "DELETE", f"exchanges/{vhost}/{exchange}")
+
+
+def exchange_exists(cluster, vhost, exchange):
+    vhost = requests.utils.quote(vhost, safe="")
+    return exchange in [result["name"] for result in list_exchanges(cluster, vhost)]
+
+
+def list_queues(cluster, vhost):
+    vhost = requests.utils.quote(vhost, safe="")
+    return _request(cluster, "GET", f"queues/{vhost}")
+
+
+def get_queue(cluster, vhost, queue):
+    vhost = requests.utils.quote(vhost, safe="")
+    queue = requests.utils.quote(queue, safe="")
+    return _request(cluster, "GET", f"queues/{vhost}/{queue}")
+
+
+def update_queue(cluster, vhost, queue, **kwargs):
+    vhost = requests.utils.quote(vhost, safe="")
+    queue = requests.utils.quote(queue, safe="")
+
+    data = {}
+    for arg in ARGS_QUEUE:
+        if arg in kwargs:
+            data[arg] = kwargs[arg]
+
+    return _request(cluster, "PUT", f"queues/{vhost}/{queue}", data)
+
+
+def delete_queue(cluster, vhost, queue):
+    vhost = requests.utils.quote(vhost, safe="")
+    queue = requests.utils.quote(queue, safe="")
+    return _request(cluster, "DELETE", f"queues/{vhost}/{queue}")
+
+
+def queue_exists(cluster, vhost, queue):
+    vhost = requests.utils.quote(vhost, safe="")
+    return queue in [result["name"] for result in list_queues(cluster, vhost)]
+
+
+def list_bindings(cluster, vhost):
+    vhost = requests.utils.quote(vhost, safe="")
+    return _request(cluster, "GET", f"bindings/{vhost}")
+
+
+def check_queue_binding(cluster, vhost, queue, exchange, **kwargs):
+    bindings = list_bindings(cluster, vhost)
+    for binding in bindings:
+        # The binding is the one we want if all fields are equal
+        if binding["source"] != exchange:
+            continue
+        if binding["destination_type"] != "queue":
+            continue
+        if binding["destination"] != queue:
+            continue
+        for arg in ARGS_BINDING:
+            if binding[arg] != kwargs[arg]:
+                continue
+
+        # We've got a winner
+        return True
+
+    return False
+
+
+def check_exchange_binding(cluster, vhost, source, destination, **kwargs):
+    bindings = list_bindings(cluster, vhost)
+    for binding in bindings:
+        # The binding is the one we want if all fields are equal
+        if binding["source"] != source:
+            continue
+        if binding["destination_type"] != "exchange":
+            continue
+        if binding["destination"] != destination:
+            continue
+        for arg in ARGS_BINDING:
+            if binding[arg] != kwargs[arg]:
+                continue
+
+        # We've got a winner
+        return True
+
+    return False
+
+
+def queue_bind(cluster, vhost, exchange, queue, **kwargs):
+    vhost = requests.utils.quote(vhost, safe="")
+    queue = requests.utils.quote(queue, safe="")
+    exchange = requests.utils.quote(exchange, safe="")
+
+    data = {}
+    for arg in ARGS_BINDING:
+        if arg in kwargs:
+            data[arg] = kwargs[arg]
+
+    return _request(cluster, "POST", f"bindings/{vhost}/e/{exchange}/q/{queue}", data)
+
+
+def exchange_bind(cluster, vhost, source, destination, **kwargs):
+    vhost = requests.utils.quote(vhost, safe="")
+    source = requests.utils.quote(source, safe="")
+    destination = requests.utils.quote(destination, safe="")
+
+    data = {}
+    for arg in ARGS_BINDING:
+        if arg in kwargs:
+            data[arg] = kwargs[arg]
+
+    return _request(
+        cluster, "POST", f"bindings/{vhost}/e/{source}/e/{destination}", data
+    )
+
+
+def get_permissions(cluster, vhost, user):
+    return _request(cluster, "GET", f"permissions/{vhost}/{user}")
+
+
+def update_permissions(cluster, vhost, user, **kwargs):
+    data = {
+        "configure": "^$",
+        "read": "^$",
+        "write": "^$",
+    }
+
+    for arg in data:
+        if arg in kwargs:
+            if kwargs[arg]:
+                data[arg] = kwargs[arg]
+
+    return _request(cluster, "PUT", f"permissions/{vhost}/{user}", data)
+
+
+#   -------------------------------------------------------------
+#   Credentials helper methods
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def compute_password_hash(password):
+    salt = secrets.randbits(32)
+    return compute_password_hash_with_salt(salt, password)
+
+
+def compute_password_hash_with_salt(salt, password):
+    """Reference: https://rabbitmq.com/passwords.html#computing-password-hash"""
+    salt = salt.to_bytes(4, "big")  # salt is a 32 bits (4 bytes) value
+
+    m = hashlib.sha256()
+    m.update(salt)
+    m.update(password.encode("utf-8"))
+    result = salt + m.digest()
+
+    return base64.b64encode(result).decode("utf-8")
diff --git a/_states/rabbitmq.py b/_states/rabbitmq.py
new file mode 100644
--- /dev/null
+++ b/_states/rabbitmq.py
@@ -0,0 +1,424 @@
+#!/usr/bin/env python3
+
+#   -------------------------------------------------------------
+#   Salt - RabbitMQ management HTTP API state module
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+#   Project:        Nasqueron
+#   Description:    Configure RabbitMQ through management HTTP API
+#   License:        BSD-2-Clause
+#   -------------------------------------------------------------
+
+
+import logging
+
+
+log = logging.getLogger(__name__)
+
+
+#   -------------------------------------------------------------
+#   User
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def user_present(name, cluster, credential, tags=[]):
+    password_hash = _get_password_hash(credential)
+    return _user_present(name, cluster, password_hash=password_hash, tags=tags)
+
+
+def _user_present(name, cluster, password_hash=None, tags=[]):
+    ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+    expected = {
+        "password_hash": password_hash,
+        "tags": tags,
+    }
+    actual = {}
+    is_existing = False
+
+    if __salt__["rabbitmq_api.user_exists"](cluster, name):
+        user = __salt__["rabbitmq_api.get_user"](cluster, name)
+        is_existing = True
+        actual = {
+            "password_hash": user["password_hash"],
+            "tags": user["tags"],
+        }
+
+    if actual == expected:
+        ret["result"] = True
+        ret["comment"] = f"User {name} is up to date"
+        return ret
+
+    ret["changes"] = _changes(actual, expected)
+    update_verb = "updated" if is_existing else "created"
+
+    if __opts__["test"]:
+        ret["result"] = None
+        ret["comment"] = f"User {name} will be {update_verb}"
+        return ret
+
+    try:
+        __salt__["rabbitmq_api.update_user"](cluster, name, **expected)
+    except Exception as e:
+        e = str(e)
+        log.error("Can't update RabbitMQ user: " + e)
+        ret["comment"] = e
+        return ret
+
+    ret["result"] = True
+    ret["comment"] = f"User {update_verb}"
+
+    return ret
+
+
+def user_absent(name, cluster):
+    ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+    if not __salt__["rabbitmq_api.user_exists"](cluster, name):
+        ret["result"] = True
+        ret["comment"] = f"User {name} is absent"
+        return ret
+
+    if __opts__["test"]:
+        ret["result"] = None
+        ret["comment"] = f"User {name} will be deleted"
+        return ret
+
+    try:
+        __salt__["rabbitmq_api.delete_user"](cluster, name)
+    except Exception as e:
+        e = str(e)
+        log.error("Can't delete RabbitMQ user: " + e)
+        ret["comment"] = e
+        return ret
+
+    ret["result"] = True
+    ret["comment"] = "User deleted"
+
+    return ret
+
+
+def user_permissions(name, cluster, vhost, user, permissions={}):
+    ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+    expected = permissions | {"vhost": vhost, "user": user}
+    actual = {}
+    is_existing = False
+
+    try:
+        actual = __salt__["rabbitmq_api.get_permissions"](cluster, vhost, user)
+        is_existing = True
+    except RuntimeError as e:
+        if "404" not in str(e):
+            raise e
+
+    if actual == expected:
+        ret["result"] = True
+        ret["comment"] = "Permission is up-to-date"
+        return ret
+
+    ret["changes"] = _changes(actual, expected)
+    update_verb = "updated" if is_existing else "created"
+
+    if __opts__["test"]:
+        ret["result"] = None
+        ret["comment"] = f"User permissions will be {update_verb}"
+        return ret
+
+    try:
+        __salt__["rabbitmq_api.update_permissions"](cluster, vhost, user, **permissions)
+    except Exception as e:
+        e = str(e)
+        log.error("Can't update RabbitMQ user permissions: " + e)
+        ret["comment"] = e
+        return ret
+
+    ret["result"] = True
+    ret["comment"] = f"User permissions {update_verb}"
+
+    return ret
+
+
+#   -------------------------------------------------------------
+#   Vhost
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def vhost_present(name, cluster, description="", tags=[], tracing=False):
+    ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+    expected = {
+        "description": description,
+        "tags": tags,
+        "tracing": tracing,
+    }
+    actual = {}
+    is_existing = False
+
+    if __salt__["rabbitmq_api.vhost_exists"](cluster, name):
+        vhost = __salt__["rabbitmq_api.get_vhost"](cluster, name)
+        is_existing = True
+        actual = {
+            "description": vhost["metadata"]["description"],
+            "tags": vhost["metadata"]["tags"],
+            "tracing": vhost["tracing"],
+        }
+
+    if actual == expected:
+        ret["result"] = True
+        ret["comment"] = f"Vhost {name} is up to date"
+        return ret
+
+    ret["changes"] = _changes(actual, expected)
+    update_verb = "updated" if is_existing else "created"
+
+    if __opts__["test"]:
+        ret["result"] = None
+        ret["comment"] = f"Vhost {name} will be {update_verb}"
+        return ret
+
+    try:
+        __salt__["rabbitmq_api.update_vhost"](cluster, name, **expected)
+    except Exception as e:
+        e = str(e)
+        log.error("Can't update RabbitMQ vhost: " + e)
+        ret["comment"] = e
+        return ret
+
+    ret["result"] = True
+    ret["comment"] = f"Vhost {update_verb}"
+
+    return ret
+
+
+def vhost_absent(name, cluster):
+    ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+    if not __salt__["rabbitmq_api.vhost_exists"](cluster, name):
+        ret["result"] = True
+        ret["comment"] = f"Vhost {name} is absent"
+        return ret
+
+    if __opts__["test"]:
+        ret["result"] = None
+        ret["comment"] = f"Vhost {name} will be deleted"
+        return ret
+
+    try:
+        __salt__["rabbitmq_api.delete_vhost"](cluster, name)
+    except Exception as e:
+        e = str(e)
+        log.error("Can't delete RabbitMQ vhost: " + e)
+        ret["comment"] = e
+        return ret
+
+    ret["result"] = True
+    ret["comment"] = "Vhost deleted"
+
+    return ret
+
+
+#   -------------------------------------------------------------
+#   Exchange
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def exchange_present(
+    name,
+    cluster,
+    vhost,
+    type,
+    auto_delete=False,
+    durable=False,
+    internal=False,
+    arguments={},
+):
+    ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+    expected = {
+        "type": type,
+        "auto_delete": auto_delete,
+        "durable": durable,
+        "internal": internal,
+        "arguments": arguments,
+    }
+    actual = {}
+    is_existing = False
+
+    if __salt__["rabbitmq_api.exchange_exists"](cluster, vhost, name):
+        exchange = __salt__["rabbitmq_api.get_exchange"](cluster, vhost, name)
+        is_existing = True
+        actual = {
+            "type": exchange["type"],
+            "auto_delete": exchange["auto_delete"],
+            "durable": exchange["durable"],
+            "internal": exchange["internal"],
+            "arguments": exchange["arguments"],
+        }
+
+    if actual == expected:
+        ret["result"] = True
+        ret["comment"] = f"Exchange {name} is up to date"
+        return ret
+
+    ret["changes"] = _changes(actual, expected)
+    update_verb = "deleted then created back" if is_existing else "created"
+
+    if __opts__["test"]:
+        ret["result"] = None
+        ret["comment"] = f"Exchange {name} will be {update_verb}"
+        return ret
+
+    try:
+        if is_existing:
+            operation = "delete"
+            __salt__["rabbitmq_api.delete_exchange"](cluster, vhost, name)
+
+        operation = "create"
+        __salt__["rabbitmq_api.update_exchange"](cluster, vhost, name, **expected)
+    except Exception as e:
+        e = str(e)
+        log.error(f"Can't {operation} RabbitMQ exchange: {e}")
+        ret["comment"] = e
+        return ret
+
+    ret["result"] = True
+    ret["comment"] = f"Exchange {update_verb}"
+
+    return ret
+
+
+#   -------------------------------------------------------------
+#   Queue
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def queue_present(
+    name, cluster, vhost, auto_delete=False, durable=False, arguments={}, node=None
+):
+    ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+    expected = {
+        "auto_delete": auto_delete,
+        "durable": durable,
+        "arguments": arguments,
+    }
+    if node is not None:
+        expected["node"] = node
+
+    actual = {}
+    is_existing = False
+
+    if __salt__["rabbitmq_api.queue_exists"](cluster, vhost, name):
+        queue = __salt__["rabbitmq_api.get_queue"](cluster, vhost, name)
+        is_existing = True
+        actual = {
+            "auto_delete": queue["auto_delete"],
+            "durable": queue["durable"],
+            "arguments": queue["arguments"],
+        }
+        if node is not None:
+            actual["node"] = queue["node"]
+
+    if actual == expected:
+        ret["result"] = True
+        ret["comment"] = f"queue {name} is up to date"
+        return ret
+
+    ret["changes"] = _changes(actual, expected)
+    update_verb = "deleted then created back" if is_existing else "created"
+
+    if __opts__["test"]:
+        ret["result"] = None
+        ret["comment"] = f"queue {name} will be {update_verb}"
+        return ret
+
+    try:
+        if is_existing:
+            operation = "delete"
+            __salt__["rabbitmq_api.delete_queue"](cluster, vhost, name)
+
+        operation = "create"
+        __salt__["rabbitmq_api.update_queue"](cluster, vhost, name, **expected)
+    except Exception as e:
+        e = str(e)
+        log.error(f"Can't {operation} RabbitMQ queue: {e}")
+        ret["comment"] = e
+        return ret
+
+    ret["result"] = True
+    ret["comment"] = f"queue {update_verb}"
+
+    return ret
+
+
+def queue_binding(name, cluster, vhost, queue, exchange, routing_key="#", arguments={}):
+    ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+    expected = {
+        "routing_key": routing_key,
+        "arguments": arguments,
+    }
+    actual = {}
+    is_existing = False
+
+    if __salt__["rabbitmq_api.check_queue_binding"](
+        cluster, vhost, queue, exchange, routing_key=routing_key, arguments=arguments
+    ):
+        ret["result"] = True
+        ret["comment"] = f"Queue already bound"
+        return ret
+
+    ret["changes"] = expected
+
+    if __opts__["test"]:
+        ret["result"] = None
+        ret["comment"] = f"Binding will be created"
+        return ret
+
+    try:
+        __salt__["rabbitmq_api.queue_bind"](cluster, vhost, exchange, queue, **expected)
+    except Exception as e:
+        e = str(e)
+        log.error("Can't create RabbitMQ binding: " + e)
+        ret["comment"] = e
+        return ret
+
+    ret["result"] = True
+    ret["comment"] = "Binding created"
+
+    return ret
+
+
+#   -------------------------------------------------------------
+#   Helper functions
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def _get_password_hash(credential):
+    secret = __salt__["vault.read_secret"](credential)
+    salt = int(secret["salt"], 16)
+    return __salt__["rabbitmq_api.compute_password_hash_with_salt"](
+        salt, secret["password"]
+    )
+
+
+def _changes(actual, expected):
+    """Compute a changes dictionary between actual and expected state dictionaries."""
+    changes = {}
+    for key, value in expected.items():
+        if key in actual and actual[key] == expected[key]:
+            continue
+
+        if "password" in key:
+            value = "*****"
+
+        if key not in actual:
+            old_value = None
+        elif "password" in key:
+            old_value = "*****"
+        else:
+            old_value = actual[key]
+
+        changes[key] = {"old": old_value, "new": value}
+
+    return changes
diff --git a/pillar/credentials/vault.sls b/pillar/credentials/vault.sls
--- a/pillar/credentials/vault.sls
+++ b/pillar/credentials/vault.sls
@@ -26,7 +26,7 @@
 #   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 
 vault_policies_path: /srv/policies/vault
-vault_policies_source: salt://roles/vault/policies/files
+vault_policies_source: /srv/policies/vault
 
 vault_mount_paths:
   ops/secrets: ops/data/secrets
diff --git a/pillar/saas/rabbitmq.sls b/pillar/saas/rabbitmq.sls
new file mode 100644
--- /dev/null
+++ b/pillar/saas/rabbitmq.sls
@@ -0,0 +1,105 @@
+#   -------------------------------------------------------------
+#   Salt — RabbitMQ
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+#   Project:        Nasqueron
+#   License:        Trivial work, not eligible to copyright
+#   -------------------------------------------------------------
+
+#   -------------------------------------------------------------
+#   RabbitMQ clusters
+#
+#   Each cluster is defined by a deployment method (e.g. docker),
+#   and the node we can use to configure it.
+#
+#   The cluster configuration is a collection of vhosts and users:
+#
+#   vhosts:
+#     <vhost name>: <configuration>
+#
+#   users:
+#     <user>: <password FULL secret path in Vault>
+#
+#     In addition, a root account is managed by deployment states.
+#
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+#
+#   The vhost configuration allows to define the exchanges and queues,
+#   and the permissions users have on them.
+#
+#   exchanges:
+#     type is 'direct', 'topic' or 'fanout'
+#
+#   queues:
+#     Application can create their own ephemeral queue.
+#     For that, it needs configure permission on the vhost.
+#
+#     If an application needs a stable one, it should be configured here,
+#     so we can drop the configure permission.
+#
+#   permissions:
+#     See https://www.rabbitmq.com/access-control.html#authorisation
+#     for the needed permissions for an AMQP operation
+#
+#     To give access to server-generated queue names, use amq\.gen.*
+#     To not give any access, use blank string
+#
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+rabbitmq_clusters:
+  white-rabbit:
+    deployment: docker
+    node: docker-002
+    container: white-rabbit
+    url: https://white-rabbit.nasqueron.org/
+
+    vhosts:
+
+      ###
+      ### Nasqueron dev services:
+      ###   - Notifications center
+      ###
+
+      dev: &nasqueron-dev-services-vhost
+        description: Nasqueron dev services
+
+        exchanges:
+          # Producer: Notifications center
+          # Consumers: any notifications client
+          notifications:
+            type: topic
+
+        queues:
+          # Used by Wearg to stream notifications to IRC
+          wearg-notifications: {}
+
+        bindings:
+          - exchange: notifications
+            queue: wearg-notifications
+            routing_key: '#'
+
+        permissions:
+          # Notifications center (paas-docker role / notifications container)
+          notifications:
+            configure: '^$'
+            read:      '^$'
+            write:     '^notifications$'
+
+          # Wearg (viperserv role)
+          wearg:
+            configure: '^$'
+            read:      '^wearg\-notifications$'
+            write:     '^$'
+
+          # Notifications CLI clients
+          notifications-ysul: &notifications-client-permissions
+            configure: '^amq\.gen.*$'
+            read:      '^(amq\.gen.*|notifications)$'
+            write:     '^amq\.gen.*$'
+          notifications-windriver: *notifications-client-permissions
+
+    users:
+      # Notifications center server and clients
+      notifications: ops/secrets/nasqueron.notifications.broker
+      wearg: apps/viperserv/broker
+      notifications-ysul: ops/secrets/nasqueron/notifications/notifications-cli/ysul
+      notifications-windriver: ops/secrets/nasqueron/notifications/notifications-cli/windriver
diff --git a/pillar/top.sls b/pillar/top.sls
--- a/pillar/top.sls
+++ b/pillar/top.sls
@@ -25,6 +25,9 @@
   complector:
     - credentials.vault
 
+    # To provision services
+    - saas.rabbitmq
+
   docker-001:
     - credentials.zr
     - notifications.config
diff --git a/roles/saas-rabbitmq/init.sls b/roles/saas-rabbitmq/init.sls
new file mode 100644
--- /dev/null
+++ b/roles/saas-rabbitmq/init.sls
@@ -0,0 +1,9 @@
+#   -------------------------------------------------------------
+#   Salt — RabbitMQ
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+#   Project:        Nasqueron
+#   License:        Trivial work, not eligible to copyright
+#   -------------------------------------------------------------
+
+include:
+  - .server
diff --git a/roles/saas-rabbitmq/server/content.sls b/roles/saas-rabbitmq/server/content.sls
new file mode 100644
--- /dev/null
+++ b/roles/saas-rabbitmq/server/content.sls
@@ -0,0 +1,141 @@
+#!py
+
+#   -------------------------------------------------------------
+#   Salt — RabbitMQ
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+#   Project:        Nasqueron
+#   License:        Trivial work, not eligible to copyright
+#                   If eligible, licensed under BSD-2-Clause
+#   -------------------------------------------------------------
+
+
+#   -------------------------------------------------------------
+#   Configuration provider
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def run():
+    config = {}
+
+    for cluster, cluster_args in __pillar__["rabbitmq_clusters"].items():
+        config |= configure_cluster(cluster, cluster_args)
+
+    return config
+
+
+#   -------------------------------------------------------------
+#   Cluster configuration
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def configure_cluster(cluster, cluster_args):
+    config = {}
+
+    for user, credential in cluster_args["users"].items():
+        id = f"rabbitmq_cluster_{cluster}_user_{user}"
+        config[id] = configure_user(cluster, user, credential)
+
+    for vhost, vhost_args in cluster_args["vhosts"].items():
+        id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}"
+        config[id] = configure_vhost(cluster, vhost, vhost_args)
+
+        for exchange, exchange_args in vhost_args.get("exchanges", {}).items():
+            id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_exchange_{exchange}"
+            config[id] = configure_exchange(cluster, vhost, exchange, exchange_args)
+
+        for queue, queue_args in vhost_args.get("queues", {}).items():
+            id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_queue_{queue}"
+            config[id] = configure_queue(cluster, vhost, queue, queue_args)
+
+        i = 0
+        for binding in vhost_args.get("bindings", []):
+            i += 1
+            id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_binding_{i}"
+            config[id] = configure_binding(cluster, vhost, binding)
+
+        for user, permission in vhost_args.get("permissions", {}).items():
+            id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_permissions_user_{user}"
+            config[id] = configure_user_permission(cluster, vhost, user, permission)
+
+    return config
+
+
+#   -------------------------------------------------------------
+#   RabbitMQ vhosts
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def configure_vhost(cluster, vhost, vhost_args):
+    return {
+        "rabbitmq.vhost_present": [
+            {"name": vhost},
+            {"cluster": cluster},
+            {"description": vhost_args.get("description", "")},
+        ]
+    }
+
+
+#   -------------------------------------------------------------
+#   RabbitMQ exchanges and queues
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def configure_exchange(cluster, vhost, exchange, exchange_args):
+    return {
+        "rabbitmq.exchange_present": [
+            {"name": exchange},
+            {"cluster": cluster},
+            {"vhost": vhost},
+            {"type": exchange_args["type"]},
+        ]
+    }
+
+
+def configure_queue(cluster, vhost, queue, queue_args):
+    return {
+        "rabbitmq.queue_present": [
+            {"name": queue},
+            {"cluster": cluster},
+            {"vhost": vhost},
+        ]
+    }
+
+
+def configure_binding(cluster, vhost, binding):
+    params = [
+        {"queue": binding["queue"]},
+        {"cluster": cluster},
+        {"vhost": vhost},
+        {"exchange": binding["exchange"]},
+    ]
+
+    if "routing_key" in binding:
+        params.append({"routing_key": binding["routing_key"]})
+
+    return {"rabbitmq.queue_binding": params}
+
+
+#   -------------------------------------------------------------
+#   RabbitMQ users and permissions
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def configure_user(cluster, user, credential):
+    return {
+        "rabbitmq.user_present": [
+            {"name": user},
+            {"cluster": cluster},
+            {"credential": credential},
+        ]
+    }
+
+
+def configure_user_permission(cluster, vhost, user, privilege):
+    return {
+        "rabbitmq.user_permissions": [
+            {"cluster": cluster},
+            {"vhost": vhost},
+            {"user": user},
+            {"permissions": privilege},
+        ]
+    }
diff --git a/roles/saas-rabbitmq/server/init.sls b/roles/saas-rabbitmq/server/init.sls
new file mode 100644
--- /dev/null
+++ b/roles/saas-rabbitmq/server/init.sls
@@ -0,0 +1,12 @@
+#   -------------------------------------------------------------
+#   Salt — RabbitMQ
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+#   Project:        Nasqueron
+#   License:        Trivial work, not eligible to copyright
+#   -------------------------------------------------------------
+
+include:
+  - .software
+
+  # Content includes vhosts, exchanges, queues, users, privileges
+  - .content
diff --git a/roles/saas-rabbitmq/server/software.sls b/roles/saas-rabbitmq/server/software.sls
new file mode 100644
--- /dev/null
+++ b/roles/saas-rabbitmq/server/software.sls
@@ -0,0 +1,8 @@
+#   -------------------------------------------------------------
+#   Salt — RabbitMQ
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+#   Project:        Nasqueron
+#   License:        Trivial work, not eligible to copyright
+#   -------------------------------------------------------------
+
+#   This state is left intentionally blank.
diff --git a/roles/vault/policies/files/salt-primary.hcl b/roles/vault/policies/files/salt-primary.hcl
--- a/roles/vault/policies/files/salt-primary.hcl
+++ b/roles/vault/policies/files/salt-primary.hcl
@@ -71,3 +71,18 @@
 path "transit/keys/*"{
   capabilities = ["create"]
 }
+
+#   -------------------------------------------------------------
+#   RabbitMQ credentials
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+{% for cluster, cluster_args in pillar.get("rabbitmq_clusters", {}).items() %}
+# Cluster: {{ cluster }}
+
+{% for user, credential in cluster_args.get("users", {}).items() %}
+path "{{ credential.replace("/", "/data/", 1) }}" {
+    capabilities = [ "read" ]
+}
+{% endfor %}
+
+{% endfor %}
diff --git a/roles/vault/policies/init.sls b/roles/vault/policies/init.sls
--- a/roles/vault/policies/init.sls
+++ b/roles/vault/policies/init.sls
@@ -25,6 +25,7 @@
 {{ policy_path }}:
   file.managed:
     - source: salt://roles/vault/policies/files/{{ policy }}.hcl
+    - template: jinja
 
 vault_policy_{{ policy }}:
   credentials.vault_policy_present:
diff --git a/services.sls b/services.sls
new file mode 100644
--- /dev/null
+++ b/services.sls
@@ -0,0 +1,14 @@
+#   -------------------------------------------------------------
+#   Salt configuration for Nasqueron servers :: services
+#   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+#   Project:        Nasqueron
+#   Description:    List of the roles configured through services API.
+#                   They are typically run on the Salt primary server,
+#                   especially as they can need Vault credentials,
+#                   but they don't touch any file *directly*.
+#   License:        Trivial work, not eligible to copyright
+#   -------------------------------------------------------------
+
+base:
+  'local':
+    - roles/saas-rabbitmq