diff --git a/roles/web-app-n8n/files/python/provision/client.py b/roles/web-app-n8n/files/python/provision/client.py new file mode 100644 index 0000000000..e1c6241d3d --- /dev/null +++ b/roles/web-app-n8n/files/python/provision/client.py @@ -0,0 +1,386 @@ +"""Provision the deployment-managed MCP client surface of n8n 1.95.3. + +Renders one agent workflow whose connected MCP Client Tool nodes are exactly +the providers this deployment discovered, each pinned to that provider's +declared tool allowlist. + +Prints ``CHANGED`` or ``OK``. + +Environment: + N8N_BASE: origin of the n8n API. + N8N_OWNER_EMAIL: owner account email. + N8N_OWNER_PASSWORD: owner account password. + N8N_API_KEY_NAME: deterministic name of the managed API key. + N8N_CLIENT_WORKFLOW: deterministic name of the managed agent workflow. + N8N_AI_CREDENTIAL: name of the managed gateway credential. + N8N_CHAT_MODEL: model the agent asks the gateway for. + N8N_MCP_PROVIDERS: JSON array of discovered providers, each carrying + ``id``, ``url``, ``token`` and ``tools``. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import urllib.error +import urllib.request + +BASE = os.environ.get("N8N_BASE", "").rstrip("/") +EMAIL = os.environ.get("N8N_OWNER_EMAIL", "") +PASSWORD = os.environ.get("N8N_OWNER_PASSWORD", "") +KEY_NAME = os.environ.get("N8N_API_KEY_NAME", "") +WORKFLOW_NAME = os.environ.get("N8N_CLIENT_WORKFLOW", "") +AI_CREDENTIAL = os.environ.get("N8N_AI_CREDENTIAL", "") +CHAT_MODEL = os.environ.get("N8N_CHAT_MODEL", "") +PROVIDERS = json.loads(os.environ.get("N8N_MCP_PROVIDERS", "[]")) + +REST = "/rest" +PUBLIC = "/api/v1" +CREDENTIAL_TYPE = "httpBearerAuth" +BEARER_AUTH = "bearerAuth" +CLIENT_PREFIX = "infinito:mcp-client:" + +TRIGGER_TYPE = "n8n-nodes-base.executeWorkflowTrigger" +TRIGGER_VERSION = 1.1 +TRIGGER_NAME = "Infinito Client Trigger" +AGENT_TYPE = "@n8n/n8n-nodes-langchain.agent" +AGENT_VERSION = 2 +AGENT_NAME = "Infinito Agent" +MODEL_TYPE = "@n8n/n8n-nodes-langchain.lmChatOpenAi" +MODEL_VERSION = 1.2 +MODEL_NAME = "Infinito Model" +CLIENT_TYPE = "@n8n/n8n-nodes-langchain.mcpClientTool" +CLIENT_VERSION = 1 + +MAIN = "main" +AI_TOOL = "ai_tool" +AI_LANGUAGE_MODEL = "ai_languageModel" +INCLUDE_SELECTED = "selected" + +SESSION = {"cookie": ""} + + +def call(path, method="GET", payload=None, api_key=None): + """Return ``(status, body)`` of one n8n API call. + + Args: + path: API path below the n8n origin. + method: HTTP method. + payload: JSON-serialisable request body, or None. + api_key: public-API key; omitted calls ride the owner cookie instead. + """ + data = json.dumps(payload).encode() if payload is not None else None + request = urllib.request.Request( # noqa: S310 - fixed http:// base from N8N_BASE, no user-supplied scheme + f"{BASE}{path}", data=data, method=method + ) + request.add_header("Content-Type", "application/json") + if api_key: + request.add_header("X-N8N-API-KEY", api_key) + elif SESSION["cookie"]: + request.add_header("Cookie", SESSION["cookie"]) + try: + with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - fixed http:// base from N8N_BASE + for header in response.headers.get_all("Set-Cookie") or []: + if header.startswith("n8n-auth="): + SESSION["cookie"] = header.split(";", 1)[0] + return response.status, json.loads(response.read() or b"null") + except urllib.error.HTTPError as error: + return error.code, error.read().decode(errors="replace") + + +def login(): + status, body = call( + f"{REST}/login", + method="POST", + payload={"emailOrLdapLoginId": EMAIL, "password": PASSWORD}, + ) + if status != 200: + sys.exit(f"FAILED logging in as {EMAIL}: {status} {body}") + + +def api_key(): + """Return ``(secret, id)`` of a public-API key minted for this run alone. + + n8n returns the usable secret only once, as ``rawApiKey``, and a listed key + is redacted, so the key cannot be carried between runs. It is minted here + and revoked before the run ends; an entry left behind under the managed name + belongs to a run that died and is deleted rather than guessed at. + """ + status, body = call(f"{REST}/api-keys") + if status != 200: + sys.exit(f"FAILED listing api keys: {status} {body}") + + existing = (body or {}).get("data") if isinstance(body, dict) else body + matches = [key for key in existing or [] if key.get("label") == KEY_NAME] + if len(matches) > 1: + sys.exit(f"FAILED: {len(matches)} api keys named {KEY_NAME}") + for key in matches: + call(f"{REST}/api-keys/{key['id']}", method="DELETE") + + status, body = call( + f"{REST}/api-keys", + method="POST", + payload={ + "label": KEY_NAME, + "scopes": [ + "workflow:create", + "workflow:read", + "workflow:update", + "workflow:list", + "workflow:delete", + ], + "expiresAt": None, + }, + ) + if status not in (200, 201): + sys.exit(f"FAILED creating api key {KEY_NAME}: {status} {body}") + created = (body or {}).get("data") if isinstance(body, dict) else body + return str(created["rawApiKey"]), str(created["id"]) + + +def revoke_key(key_id): + """Delete the public-API key this run minted. + + Args: + key_id: id of the key to delete. + """ + status, body = call(f"{REST}/api-keys/{key_id}", method="DELETE") + if status != 200: + sys.exit(f"FAILED revoking api key {KEY_NAME}: {status} {body}") + + +def node_name(provider_id): + """Return the stable node name of one provider's client tool. + + Args: + provider_id: the provider's application_id. + """ + return "mcp_" + re.sub(r"[^a-zA-Z0-9]+", "_", provider_id).strip("_") + + +def credential_name(provider_id): + """Return the deterministic name of one provider's bearer credential. + + Args: + provider_id: the provider's application_id. + """ + return f"{CLIENT_PREFIX}{provider_id}" + + +def listed_credentials(): + """Return the credentials n8n currently holds.""" + status, body = call(f"{REST}/credentials") + if status != 200: + sys.exit(f"FAILED listing credentials: {status} {body}") + existing = (body or {}).get("data") if isinstance(body, dict) else body + return list(existing or []) + + +def credential(provider): + """Return ``({"id", "name"}, created)`` of one provider's bearer credential. + + Args: + provider: a discovered provider carrying ``id`` and ``token``. + """ + name = credential_name(provider["id"]) + matches = [item for item in listed_credentials() if item.get("name") == name] + if len(matches) > 1: + sys.exit(f"FAILED: {len(matches)} credentials named {name}") + + payload = { + "name": name, + "type": CREDENTIAL_TYPE, + "data": {"token": provider["token"]}, + } + if not matches: + status, body = call(f"{REST}/credentials", method="POST", payload=payload) + if status not in (200, 201): + sys.exit(f"FAILED creating {name}: {status} {body}") + created = (body or {}).get("data") if isinstance(body, dict) else body + return {"id": str(created["id"]), "name": name}, True + + status, body = call( + f"{REST}/credentials/{matches[0]['id']}", method="PATCH", payload=payload + ) + if status != 200: + sys.exit(f"FAILED updating {name}: {status} {body}") + return {"id": str(matches[0]["id"]), "name": name}, False + + +def drop_stale_credentials(keep): + """Delete managed client credentials no discovered provider claims. + + Args: + keep: credential names the current provider set still needs. + + A name outside ``CLIENT_PREFIX`` belongs to a human and is never touched. + """ + dropped = False + for item in listed_credentials(): + name = str(item.get("name") or "") + if name.startswith(CLIENT_PREFIX) and name not in keep: + status, body = call(f"{REST}/credentials/{item['id']}", method="DELETE") + if status != 200: + sys.exit(f"FAILED deleting {name}: {status} {body}") + dropped = True + return dropped + + +def client_node(provider, reference, index): + """Return one MCP Client Tool node pinned to a provider's allowlist. + + Args: + provider: a discovered provider carrying ``id``, ``url`` and ``tools``. + reference: ``{"id", "name"}`` reference to its bearer credential. + index: position of the node, so the rendering stays stable. + """ + name = node_name(provider["id"]) + mutating = set(provider.get("mutating") or []) + return { + "id": name, + "name": name, + "type": CLIENT_TYPE, + "typeVersion": CLIENT_VERSION, + "position": [220 * index, 220], + "parameters": { + "sseEndpoint": provider["url"], + "authentication": BEARER_AUTH, + "include": INCLUDE_SELECTED, + "includeTools": sorted(set(provider.get("tools") or []) - mutating), + }, + "credentials": {CREDENTIAL_TYPE: reference}, + } + + +def workflow_body(references): + """Return the managed agent workflow as the public API accepts it. + + Args: + references: ``{provider_id: {"id", "name"}}`` bearer references. + """ + nodes = [ + { + "id": "infinito-client-trigger", + "name": TRIGGER_NAME, + "type": TRIGGER_TYPE, + "typeVersion": TRIGGER_VERSION, + "position": [0, 0], + "parameters": {}, + }, + { + "id": "infinito-agent", + "name": AGENT_NAME, + "type": AGENT_TYPE, + "typeVersion": AGENT_VERSION, + "position": [220, 0], + "parameters": {}, + }, + { + "id": "infinito-model", + "name": MODEL_NAME, + "type": MODEL_TYPE, + "typeVersion": MODEL_VERSION, + "position": [220, 220], + "parameters": {"model": CHAT_MODEL}, + "credentials": {"openAiApi": {"name": AI_CREDENTIAL}}, + }, + ] + connections = { + TRIGGER_NAME: {MAIN: [[{"node": AGENT_NAME, "type": MAIN, "index": 0}]]}, + MODEL_NAME: { + AI_LANGUAGE_MODEL: [ + [{"node": AGENT_NAME, "type": AI_LANGUAGE_MODEL, "index": 0}] + ] + }, + } + for index, provider in enumerate(PROVIDERS): + nodes.append(client_node(provider, references[provider["id"]], index + 2)) + connections[node_name(provider["id"])] = { + AI_TOOL: [[{"node": AGENT_NAME, "type": AI_TOOL, "index": 0}]] + } + return { + "name": WORKFLOW_NAME, + "settings": {"executionOrder": "v1"}, + "nodes": nodes, + "connections": connections, + } + + +def managed_workflow(key): + """Return the managed agent workflow, or None, refusing to guess between two. + + Args: + key: the public-API key. + """ + status, body = call(f"{PUBLIC}/workflows", api_key=key) + if status != 200: + sys.exit(f"FAILED listing workflows: {status} {body}") + existing = (body or {}).get("data") if isinstance(body, dict) else body + matches = [flow for flow in existing or [] if flow.get("name") == WORKFLOW_NAME] + if len(matches) > 1: + sys.exit(f"FAILED: {len(matches)} workflows named {WORKFLOW_NAME}") + return matches[0] if matches else None + + +def reconcile(key): + """Render the agent workflow over exactly the providers this run discovered. + + Args: + key: the public-API key. + + The workflow is never activated: it answers a sub-workflow call, so it runs + only once an operator wires something to it. + """ + flow = managed_workflow(key) + if not PROVIDERS: + dropped = drop_stale_credentials(set()) + if flow is not None: + status, body = call( + f"{PUBLIC}/workflows/{flow['id']}", method="DELETE", api_key=key + ) + if status != 200: + sys.exit(f"FAILED deleting {WORKFLOW_NAME}: {status} {body}") + return dropped or flow is not None + + references = {} + minted = False + for provider in PROVIDERS: + references[provider["id"]], created = credential(provider) + minted = minted or created + dropped = drop_stale_credentials( + {credential_name(provider["id"]) for provider in PROVIDERS} + ) + + payload = workflow_body(references) + if flow is None: + status, body = call( + f"{PUBLIC}/workflows", method="POST", payload=payload, api_key=key + ) + if status not in (200, 201): + sys.exit(f"FAILED creating {WORKFLOW_NAME}: {status} {body}") + return True + + changed = flow.get("nodes") != payload["nodes"] + status, body = call( + f"{PUBLIC}/workflows/{flow['id']}", + method="PUT", + payload=payload, + api_key=key, + ) + if status != 200: + sys.exit(f"FAILED updating {WORKFLOW_NAME}: {status} {body}") + return changed or minted or dropped + + +def main(): + login() + key, key_id = api_key() + changed = reconcile(key) + revoke_key(key_id) + print("CHANGED" if changed else "OK") + + +if __name__ == "__main__": + main() diff --git a/roles/web-app-n8n/files/python/provision/mcp.py b/roles/web-app-n8n/files/python/provision/mcp.py index c0322bdb33..c2f6b09d6f 100644 --- a/roles/web-app-n8n/files/python/provision/mcp.py +++ b/roles/web-app-n8n/files/python/provision/mcp.py @@ -1,6 +1,6 @@ """Provision the deployment-managed MCP surfaces of n8n 1.95.3. -Prints ``CHANGED``/``OK`` and then one JSON line carrying ``api_key``. +Prints ``CHANGED`` or ``OK``. Environment: N8N_BASE: origin of the n8n API. @@ -34,9 +34,36 @@ REST = "/rest" PUBLIC = "/api/v1" TRIGGER_TYPE = "@n8n/n8n-nodes-langchain.mcpTrigger" TRIGGER_VERSION = 1 +TRIGGER_NAME = "Infinito MCP Trigger" BEARER_AUTH = "bearerAuth" CREDENTIAL_TYPE = "httpBearerAuth" +TOOL_TYPE = "@n8n/n8n-nodes-langchain.toolCode" +TOOL_VERSION = 1.2 +TOOL_NAME = "infinito_health" +TOOL_CONNECTION = "ai_tool" +TOOL_DESCRIPTION = ( + "Report that the managed MCP server is reachable and echo the caller's text " + "back. Reads nothing: it reaches no application, database, or network." +) +TOOL_CODE = ( + 'return JSON.stringify({status: "ok", echo: query.echo, ' + "checked_at: new Date().toISOString()});" +) +TOOL_SCHEMA = json.dumps( + { + "type": "object", + "properties": { + "echo": { + "type": "string", + "description": "Text returned unchanged, to prove the round trip.", + } + }, + "required": ["echo"], + "additionalProperties": False, + } +) + SESSION = {"cookie": ""} @@ -79,11 +106,12 @@ def login(): def api_key(): - """Return the managed public-API key, reusing a previous run's when present. + """Return ``(secret, id)`` of a public-API key minted for this run alone. - n8n returns the usable secret only once, as ``rawApiKey``. A listed key is - redacted, so an existing entry under the managed name is deleted and minted - again rather than guessed at. + n8n returns the usable secret only once, as ``rawApiKey``, and a listed key + is redacted, so the key cannot be carried between runs. It is minted here + and revoked before the run ends; an entry left behind under the managed name + belongs to a run that died and is deleted rather than guessed at. """ status, body = call(f"{REST}/api-keys") if status != 200: @@ -113,7 +141,18 @@ def api_key(): if status not in (200, 201): sys.exit(f"FAILED creating api key {KEY_NAME}: {status} {body}") created = (body or {}).get("data") if isinstance(body, dict) else body - return str(created["rawApiKey"]), bool(matches) + return str(created["rawApiKey"]), str(created["id"]) + + +def revoke_key(key_id): + """Delete the public-API key this run minted. + + Args: + key_id: id of the key to delete. + """ + status, body = call(f"{REST}/api-keys/{key_id}", method="DELETE") + if status != 200: + sys.exit(f"FAILED revoking api key {KEY_NAME}: {status} {body}") def bearer_credential(): @@ -155,6 +194,9 @@ def bearer_credential(): def workflow_body(credential): """Return the managed MCP server workflow as the public API accepts it. + The trigger serves exactly the tools reaching it over an ``ai_tool`` + connection, so the connection map is the tool allowlist. + Args: credential: ``{"id", "name"}`` reference to the managed bearer credential. """ @@ -164,7 +206,7 @@ def workflow_body(credential): "nodes": [ { "id": "infinito-mcp-trigger", - "name": "Infinito MCP Trigger", + "name": TRIGGER_NAME, "type": TRIGGER_TYPE, "typeVersion": TRIGGER_VERSION, "position": [0, 0], @@ -174,9 +216,30 @@ def workflow_body(credential): "authentication": BEARER_AUTH, }, "credentials": {CREDENTIAL_TYPE: credential}, - } + }, + { + "id": "infinito-mcp-health", + "name": TOOL_NAME, + "type": TOOL_TYPE, + "typeVersion": TOOL_VERSION, + "position": [0, 220], + "parameters": { + "description": TOOL_DESCRIPTION, + "language": "javaScript", + "jsCode": TOOL_CODE, + "specifyInputSchema": True, + "schemaType": "manual", + "inputSchema": TOOL_SCHEMA, + }, + }, ], - "connections": {}, + "connections": { + TOOL_NAME: { + TOOL_CONNECTION: [ + [{"node": TRIGGER_NAME, "type": TOOL_CONNECTION, "index": 0}] + ] + } + }, } @@ -240,11 +303,11 @@ def main(): if not MCP_TOKEN: sys.exit("FAILED: no bearer configured; the trigger would be unauthenticated") login() - key, rotated = api_key() + key, key_id = api_key() credential, minted = bearer_credential() changed = upsert_workflow(key, credential) - print(f"{'CHANGED' if changed or minted or rotated else 'OK'}") - print(json.dumps({"api_key": key})) + revoke_key(key_id) + print(f"{'CHANGED' if changed or minted else 'OK'}") if __name__ == "__main__": diff --git a/roles/web-app-n8n/meta/mcp.yml b/roles/web-app-n8n/meta/mcp.yml index 4604c0b4f1..7c06235cae 100644 --- a/roles/web-app-n8n/meta/mcp.yml +++ b/roles/web-app-n8n/meta/mcp.yml @@ -30,5 +30,7 @@ endpoint: path: /webhook/infinito-mcp/sse port_key: http tools: + allowlist: + - infinito_health read_only_default: true mutating_tools_enabled: false diff --git a/roles/web-app-n8n/meta/services.yml b/roles/web-app-n8n/meta/services.yml index 5fbbbbf3ab..59ce5070ad 100644 --- a/roles/web-app-n8n/meta/services.yml +++ b/roles/web-app-n8n/meta/services.yml @@ -9,6 +9,9 @@ sso: origin: host: n8n port: 5678 + allowed_groups: + - "{{ lookup('rbac_group_path', application_id='web-app-n8n', role='administrator') }}" + - "{{ lookup('rbac_group_path', application_id='web-app-n8n', role='mcp') }}" email: bond: 1 enabled: "{{ 'web-app-mailu' in group_names }}" diff --git a/roles/web-app-n8n/tasks/utils/mcp.yml b/roles/web-app-n8n/tasks/utils/mcp.yml index 4f30837ee3..da370e7e59 100644 --- a/roles/web-app-n8n/tasks/utils/mcp.yml +++ b/roles/web-app-n8n/tasks/utils/mcp.yml @@ -31,3 +31,35 @@ sys_token_store_user_key: "{{ lookup('config', application_id, 'mcp.credential.owner') }}" sys_token_store_app: "{{ application_id }}" sys_token_store_token: "{{ N8N_MCP_TOKEN }}" + +- name: "🔌 Reconcile the managed agent over every discovered MCP provider" + ansible.builtin.command: + argv: + - python3 + - "-" + stdin: "{{ lookup('file', 'python/provision/client.py') }}" + environment: + N8N_AI_CREDENTIAL: "{{ N8N_LITELLM_CREDENTIAL_NAME }}" + N8N_API_KEY_NAME: "{{ N8N_MCP_API_KEY_NAME }}" + N8N_BASE: "{{ N8N_INTERNAL_API_URL }}" + N8N_CHAT_MODEL: "{{ LITELLM_CHAT_MODEL }}" + N8N_CLIENT_WORKFLOW: "{{ N8N_MCP_CLIENT_WORKFLOW_NAME }}" + N8N_MCP_PROVIDERS: "{{ N8N_MCP_PROVIDERS | to_json }}" + N8N_OWNER_EMAIL: "{{ N8N_OWNER_EMAIL }}" + N8N_OWNER_PASSWORD: "{{ N8N_OWNER_PASSWORD }}" + register: n8n_client_provision + changed_when: n8n_client_provision.stdout.startswith('CHANGED') + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + delegate_to: "{{ STACK_HOST }}" + run_once: true + when: N8N_LITELLM_ENABLED | bool + +- name: "🩺 Prove the MCP contract of {{ application_id }}" + ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/svc-ai-mcp-adapter/tasks/probe.yml') }}" + vars: + mcp_probe_bearer: "{{ lookup('mcp_credential', application_id) }}" + mcp_probe_path_key: "" + mcp_probe_read_tool: "infinito_health" + mcp_probe_read_args: + echo: "infinito" + mcp_probe_host: "" diff --git a/roles/web-app-n8n/vars/main.yml b/roles/web-app-n8n/vars/main.yml index d381f8cc38..fec5820819 100644 --- a/roles/web-app-n8n/vars/main.yml +++ b/roles/web-app-n8n/vars/main.yml @@ -22,6 +22,8 @@ N8N_MCP_API_KEY_NAME: "infinito:mcp" N8N_MCP_WORKFLOW_NAME: "infinito:mcp-server" N8N_MCP_CREDENTIAL_NAME: "infinito:mcp-bearer" N8N_MCP_WEBHOOK_PATH: "infinito-mcp" +N8N_MCP_CLIENT_WORKFLOW_NAME: "infinito:mcp-agent" +N8N_MCP_PROVIDERS: "{{ MCP_DISCOVERED_SERVERS if N8N_MCP_ENABLED else [] }}" N8N_LITELLM_ENABLED: "{{ lookup('config', application_id, 'services.litellm.enabled') | bool }}" N8N_LITELLM_BASE_URL: "{{ LITELLM_OPENAI_BASE_LOCAL_URL }}" diff --git a/tests/lint/ansible/services/test_mcp_client_gate_scoped.py b/tests/lint/ansible/services/test_mcp_client_gate_scoped.py new file mode 100644 index 0000000000..0ee26f4125 --- /dev/null +++ b/tests/lint/ansible/services/test_mcp_client_gate_scoped.py @@ -0,0 +1,123 @@ +"""Lint: signing in to an MCP client does not by itself grant its MCP surface. + +An MCP client holds credentials the deployment issued, not the caller's own, so +whoever reaches its UI reaches every provider that client is registered with, +under an identity that is not theirs. None of these upstreams offers a tested +per-server user authorization boundary, so the boundary has to be the gate in +front of them: the connection is restricted to an administrator and to the +application's own ``mcp`` group, never offered to every account that happens to +have a login. + +An oauth2 gate with no ``allowed_groups`` admits every authenticated user in the +realm, which is exactly the failure this rule exists for — ``web-app-n8n`` +shipped that way while declaring an ``mcp`` role nothing consulted. + +Two relations per client role whose gate is an oauth2 proxy: + +* the gate names ``allowed_groups`` at all; +* every group it names is scoped to that role's own ``application_id``, so a + group belonging to another application cannot open this one. + +Suppression (see ``docs/contributing/actions/testing/suppression.md``): + +* ``# nocheck: mcp-client-gate-scoped`` in the head of the role's + ``meta/mcp.yml``, together with the per-server boundary that replaces it. +""" + +from __future__ import annotations + +import unittest +from collections.abc import Mapping +from pathlib import Path + +from utils.annotations.suppress import is_suppressed_in_head +from utils.cache.files import read_text +from utils.cache.yaml import load_yaml_any +from utils.roles.mapping import ROLE_FILE_META_MCP, ROLE_FILE_META_SERVICES + +from . import PROJECT_ROOT + +_RULE = "mcp-client-gate-scoped" +_CLIENT_DIRECTIONS = frozenset({"client", "both"}) + + +def _gated_client_roles() -> dict[str, Mapping]: + """Return ``{role: sso block}`` for MCP clients standing behind an oauth2 gate.""" + roles: dict[str, Mapping] = {} + for mcp_path in sorted(Path(PROJECT_ROOT, "roles").glob(f"*/{ROLE_FILE_META_MCP}")): + mcp = load_yaml_any(str(mcp_path), default_if_missing={}) + if not isinstance(mcp, Mapping): + continue + if str(mcp.get("direction") or "").strip().lower() not in _CLIENT_DIRECTIONS: + continue + if is_suppressed_in_head(read_text(str(mcp_path)).splitlines(), _RULE): + continue + role_dir = mcp_path.parent.parent + services = load_yaml_any( + str(role_dir / ROLE_FILE_META_SERVICES), default_if_missing={} + ) + sso = (services or {}).get("sso") + if not isinstance(sso, Mapping): + continue + if str(sso.get("flavor") or "").strip() != "oauth2": + continue + roles[role_dir.name] = sso + return roles + + +def _allowed_groups(sso: Mapping) -> list[str]: + oauth2 = sso.get("oauth2") + if not isinstance(oauth2, Mapping): + return [] + groups = oauth2.get("allowed_groups") + return [str(g) for g in groups] if isinstance(groups, list) else [] + + +class TestMcpClientGateScoped(unittest.TestCase): + def test_every_gated_client_restricts_its_groups(self) -> None: + open_gates = [ + f"{role}: its oauth2 gate names no allowed_groups, so every " + f"authenticated account in the realm reaches an MCP client that " + f"acts with the deployment's provider credentials" + for role, sso in sorted(_gated_client_roles().items()) + if not _allowed_groups(sso) + ] + self.assertEqual( + [], + open_gates, + f"MCP client(s) behind an unrestricted gate ({len(open_gates)}):\n" + + "\n".join(f" - {g}" for g in open_gates), + ) + + def test_every_allowed_group_is_application_scoped(self) -> None: + foreign = [] + for role, sso in sorted(_gated_client_roles().items()): + for group in _allowed_groups(sso): + if ( + f"application_id='{role}'" in group + or f'application_id="{role}"' in group + ): + continue + foreign.append( + f"{role}: allows '{group}', which is not scoped to this " + f"role's own application_id, so another application's " + f"membership opens this client" + ) + self.assertEqual( + [], + foreign, + "MCP client(s) allowing a foreign group:\n" + + "\n".join(f" - {f}" for f in foreign), + ) + + def test_the_scan_finds_gated_clients(self) -> None: + self.assertTrue( + _gated_client_roles(), + "no MCP client role sits behind an oauth2 gate, so both rules " + "would pass vacuously; check that the scan still reads the right " + "topic", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py b/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py new file mode 100644 index 0000000000..d698b892dc --- /dev/null +++ b/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py @@ -0,0 +1,267 @@ +"""Unit tests for ``roles/web-app-n8n/files/python/provision/client.py``.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import unittest +from unittest.mock import patch + +from . import PROJECT_ROOT + +SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-n8n/files/python/provision/client.py" + +PROVIDER = { + "id": "web-app-gitea", + "url": "http://gitea:3000/mcp/sse", + "token": "g" * 40, + "tools": ["gitea_list_repos", "gitea_get_issue"], +} + +ENV = { + "N8N_BASE": "http://n8n:5678", + "N8N_OWNER_EMAIL": "administrator@example.org", + "N8N_OWNER_PASSWORD": "x" * 48, + "N8N_API_KEY_NAME": "infinito:mcp", + "N8N_CLIENT_WORKFLOW": "infinito:mcp-agent", + "N8N_AI_CREDENTIAL": "infinito:litellm", + "N8N_CHAT_MODEL": "llama3", + "N8N_MCP_PROVIDERS": json.dumps([PROVIDER]), +} + +REFERENCE = {"id": "c1", "name": "infinito:mcp-client:web-app-gitea"} + + +def load_script(overrides=None) -> object: + spec = importlib.util.spec_from_file_location("provision_client", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + with patch.dict("os.environ", {**ENV, **(overrides or {})}): + spec.loader.exec_module(module) + return module + + +class FakeApi: + """Minimal stand-in for the n8n rest and public API surfaces.""" + + def __init__(self, keys=(), workflows=(), credentials=()): + self.keys = [dict(key) for key in keys] + self.workflows = [dict(flow) for flow in workflows] + self.credentials = [dict(item) for item in credentials] + self.created_workflows = [] + self.updated_workflows = [] + self.deleted_workflows = [] + self.written_credentials = [] + self.deleted_credentials = [] + self.deleted_keys = [] + self.logins = 0 + self.minted = 0 + + def __call__(self, path, method="GET", payload=None, api_key=None): + if path == "/rest/login": + self.logins += 1 + return 200, {"data": {"id": "owner"}} + if path == "/rest/credentials" and method == "GET": + return 200, {"data": [dict(item) for item in self.credentials]} + if path == "/rest/credentials" and method == "POST": + self.minted += 1 + new = {"id": f"c{self.minted}", "name": payload["name"]} + self.written_credentials.append(payload) + self.credentials.append(new) + return 200, {"data": new} + if path.startswith("/rest/credentials/") and method == "PATCH": + self.written_credentials.append(payload) + return 200, {"data": {"id": path.rsplit("/", 1)[-1]}} + if path.startswith("/rest/credentials/") and method == "DELETE": + dead = path.rsplit("/", 1)[-1] + self.deleted_credentials.append(dead) + self.credentials = [c for c in self.credentials if c["id"] != dead] + return 200, {"success": True} + if path == "/rest/api-keys" and method == "GET": + return 200, {"data": [dict(key) for key in self.keys]} + if path == "/rest/api-keys" and method == "POST": + self.keys = [{"id": "k1", "label": payload["label"]}] + return 200, {"data": {"id": "k1", "rawApiKey": "n8n-raw-key"}} + if path.startswith("/rest/api-keys/") and method == "DELETE": + self.deleted_keys.append(path.rsplit("/", 1)[-1]) + return 200, {"success": True} + if path == "/api/v1/workflows" and method == "GET": + return 200, {"data": [dict(flow) for flow in self.workflows]} + if path == "/api/v1/workflows" and method == "POST": + self.created_workflows.append(payload) + return 200, dict(payload, id="w1", active=False) + if path.startswith("/api/v1/workflows/") and method == "PUT": + self.updated_workflows.append(payload) + return 200, payload + if path.startswith("/api/v1/workflows/") and method == "DELETE": + self.deleted_workflows.append(path.rsplit("/", 1)[-1]) + return 200, {"success": True} + raise AssertionError(f"unexpected {method} {path}") + + +class TestProvisionClient(unittest.TestCase): + def test_a_client_node_is_pinned_to_the_providers_allowlist(self) -> None: + module = load_script() + node = module.client_node(PROVIDER, REFERENCE, 2) + self.assertEqual("@n8n/n8n-nodes-langchain.mcpClientTool", node["type"]) + self.assertEqual("selected", node["parameters"]["include"]) + self.assertEqual( + ["gitea_get_issue", "gitea_list_repos"], + node["parameters"]["includeTools"], + ) + self.assertEqual("http://gitea:3000/mcp/sse", node["parameters"]["sseEndpoint"]) + self.assertEqual("bearerAuth", node["parameters"]["authentication"]) + + def test_a_tool_the_provider_calls_mutating_never_reaches_the_agent(self) -> None: + module = load_script() + provider = { + "id": "web-app-baserow", + "url": "http://baserow:8080/mcp", + "token": "b" * 40, + "tools": ["list_databases", "list_tables", "create_rows"], + "mutating": ["create_rows"], + } + node = module.client_node(provider, REFERENCE, 2) + self.assertEqual( + ["list_databases", "list_tables"], node["parameters"]["includeTools"] + ) + + def test_a_provider_without_an_allowlist_gets_no_tools(self) -> None: + module = load_script() + bare = {"id": "web-app-x", "url": "http://x/sse", "token": "t"} + node = module.client_node(bare, REFERENCE, 2) + self.assertEqual([], node["parameters"]["includeTools"]) + self.assertEqual("selected", node["parameters"]["include"]) + + def test_every_client_node_reaches_the_agent(self) -> None: + module = load_script() + body = module.workflow_body({"web-app-gitea": REFERENCE}) + self.assertEqual( + {"ai_tool": [[{"node": "Infinito Agent", "type": "ai_tool", "index": 0}]]}, + body["connections"]["mcp_web_app_gitea"], + ) + self.assertEqual("infinito:mcp-agent", body["name"]) + + def test_the_agent_reaches_the_gateway_model(self) -> None: + module = load_script() + body = module.workflow_body({"web-app-gitea": REFERENCE}) + model = next(node for node in body["nodes"] if node["id"] == "infinito-model") + self.assertEqual("llama3", model["parameters"]["model"]) + self.assertEqual( + {"openAiApi": {"name": "infinito:litellm"}}, model["credentials"] + ) + + def test_each_provider_gets_its_own_named_bearer(self) -> None: + module = load_script() + api = FakeApi() + with patch.object(module, "call", api): + module.main() + self.assertEqual( + ["infinito:mcp-client:web-app-gitea"], + [item["name"] for item in api.written_credentials], + ) + self.assertEqual({"token": "g" * 40}, api.written_credentials[0]["data"]) + + def test_the_agent_workflow_is_never_activated(self) -> None: + module = load_script() + api = FakeApi() + with patch.object(module, "call", api): + module.main() + self.assertEqual( + ["infinito:mcp-agent"], [flow["name"] for flow in api.created_workflows] + ) + + def test_the_run_leaves_no_api_key_behind(self) -> None: + module = load_script() + api = FakeApi() + with patch.object(module, "call", api): + module.main() + self.assertEqual(["k1"], api.deleted_keys) + + def test_a_rotated_provider_token_replaces_the_stored_one(self) -> None: + module = load_script() + api = FakeApi(credentials=[dict(REFERENCE)]) + with patch.object(module, "call", api): + module.main() + sent = api.written_credentials + self.assertEqual([{"token": "g" * 40}], [item["data"] for item in sent]) + self.assertNotIn("stale", json.dumps(sent)) + + def test_a_provider_that_vanished_loses_its_credential(self) -> None: + module = load_script({"N8N_MCP_PROVIDERS": "[]"}) + api = FakeApi( + credentials=[ + {"id": "gone", "name": "infinito:mcp-client:web-app-openproject"}, + {"id": "human", "name": "my own bearer"}, + ] + ) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["gone"], api.deleted_credentials) + + def test_the_agent_workflow_goes_when_no_provider_is_left(self) -> None: + module = load_script({"N8N_MCP_PROVIDERS": "[]"}) + api = FakeApi( + workflows=[{"id": "agent", "name": "infinito:mcp-agent", "active": False}] + ) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["agent"], api.deleted_workflows) + + def test_nothing_happens_when_there_is_nothing_to_reach(self) -> None: + module = load_script({"N8N_MCP_PROVIDERS": "[]"}) + api = FakeApi(credentials=[{"id": "human", "name": "my own bearer"}]) + out = io.StringIO() + with patch.object(module, "call", api), contextlib.redirect_stdout(out): + module.main() + self.assertEqual("OK", out.getvalue().strip()) + self.assertEqual([], api.deleted_credentials) + self.assertEqual([], api.created_workflows) + + def test_a_converged_deployment_reruns_unchanged(self) -> None: + module = load_script() + api = FakeApi( + credentials=[REFERENCE], + workflows=[ + { + "id": "agent", + "name": "infinito:mcp-agent", + "nodes": module.workflow_body({"web-app-gitea": REFERENCE})[ + "nodes" + ], + } + ], + ) + out = io.StringIO() + with patch.object(module, "call", api), contextlib.redirect_stdout(out): + module.main() + self.assertEqual("OK", out.getvalue().strip()) + + def test_two_managed_workflows_abort_rather_than_guess(self) -> None: + module = load_script() + api = FakeApi( + workflows=[ + {"id": "a", "name": "infinito:mcp-agent"}, + {"id": "b", "name": "infinito:mcp-agent"}, + ] + ) + with patch.object(module, "call", api), self.assertRaises(SystemExit) as exit_: + module.main() + self.assertIn("2 workflows", str(exit_.exception)) + + def test_two_managed_credentials_abort_rather_than_guess(self) -> None: + module = load_script() + api = FakeApi( + credentials=[ + {"id": "a", "name": "infinito:mcp-client:web-app-gitea"}, + {"id": "b", "name": "infinito:mcp-client:web-app-gitea"}, + ] + ) + with patch.object(module, "call", api), self.assertRaises(SystemExit) as exit_: + module.main() + self.assertIn("2 credentials", str(exit_.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py b/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py index 6845f15e95..eb666a103f 100644 --- a/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py +++ b/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py @@ -2,7 +2,10 @@ from __future__ import annotations +import contextlib import importlib.util +import io +import json import unittest from unittest.mock import patch @@ -42,9 +45,12 @@ class FakeApi: self.created_workflows = [] self.updated_workflows = [] self.activated_workflows = [] + self.deleted_workflows = [] self.written_credentials = [] + self.deleted_credentials = [] self.deleted_keys = [] self.logins = 0 + self.minted = 0 def __call__(self, path, method="GET", payload=None, api_key=None): if path == "/rest/login": @@ -53,12 +59,19 @@ class FakeApi: if path == "/rest/credentials" and method == "GET": return 200, {"data": [dict(item) for item in self.credentials]} if path == "/rest/credentials" and method == "POST": + self.minted += 1 + new = {"id": f"c{self.minted}", "name": payload["name"]} self.written_credentials.append(payload) - self.credentials = [{"id": "c1", "name": payload["name"]}] - return 200, {"data": {"id": "c1", "name": payload["name"]}} + self.credentials.append(new) + return 200, {"data": new} if path.startswith("/rest/credentials/") and method == "PATCH": self.written_credentials.append(payload) return 200, {"data": {"id": path.rsplit("/", 1)[-1]}} + if path.startswith("/rest/credentials/") and method == "DELETE": + dead = path.rsplit("/", 1)[-1] + self.deleted_credentials.append(dead) + self.credentials = [c for c in self.credentials if c["id"] != dead] + return 200, {"success": True} if path == "/rest/api-keys" and method == "GET": return 200, {"data": [dict(key) for key in self.keys]} if path == "/rest/api-keys" and method == "POST": @@ -78,6 +91,9 @@ class FakeApi: if path.endswith("/activate") and method == "POST": self.activated_workflows.append(path.split("/")[-2]) return 200, {"active": True} + if path.startswith("/api/v1/workflows/") and method == "DELETE": + self.deleted_workflows.append(path.rsplit("/", 1)[-1]) + return 200, {"success": True} raise AssertionError(f"unexpected {method} {path}") @@ -128,6 +144,19 @@ class TestProvisionMcp(unittest.TestCase): node = api.created_workflows[0]["nodes"][0] self.assertEqual("c9", node["credentials"]["httpBearerAuth"]["id"]) + def test_a_rotated_bearer_replaces_the_one_the_credential_carries(self) -> None: + module = load_script() + api = FakeApi(credentials=[{"id": "c9", "name": "infinito:mcp-bearer"}]) + with patch.object(module, "call", api): + module.main() + self.assertEqual( + [{"token": "t" * 48}], + [item["data"] for item in api.written_credentials], + "n8n never hands an existing credential's secret back, so the " + "managed one is overwritten rather than compared; a run that " + "skipped the write would leave the revoked bearer in place", + ) + def test_two_managed_credentials_abort_rather_than_guess(self) -> None: module = load_script() api = FakeApi( @@ -153,7 +182,14 @@ class TestProvisionMcp(unittest.TestCase): api = FakeApi(keys=[{"id": "old", "label": "infinito:mcp"}]) with patch.object(module, "call", api): module.main() - self.assertEqual(["old"], api.deleted_keys) + self.assertEqual(["old", "k1"], api.deleted_keys) + + def test_the_run_leaves_no_api_key_behind(self) -> None: + module = load_script() + api = FakeApi() + with patch.object(module, "call", api): + module.main() + self.assertEqual(["k1"], api.deleted_keys) def test_two_managed_keys_abort_rather_than_guess(self) -> None: module = load_script() @@ -198,6 +234,79 @@ class TestProvisionMcp(unittest.TestCase): self.assertEqual("bearerAuth", node["parameters"]["authentication"]) self.assertIn("httpBearerAuth", node["credentials"]) + def test_the_only_served_tool_is_the_connected_one(self) -> None: + module = load_script() + body = module.workflow_body(REFERENCE) + tool = body["nodes"][1] + self.assertEqual("@n8n/n8n-nodes-langchain.toolCode", tool["type"]) + self.assertEqual( + { + "infinito_health": { + "ai_tool": [ + [ + { + "node": "Infinito MCP Trigger", + "type": "ai_tool", + "index": 0, + } + ] + ] + } + }, + body["connections"], + ) + + def test_the_served_tool_declares_its_input_schema(self) -> None: + module = load_script() + tool = module.workflow_body(REFERENCE)["nodes"][1] + self.assertIs(True, tool["parameters"]["specifyInputSchema"]) + self.assertEqual("manual", tool["parameters"]["schemaType"]) + schema = json.loads(tool["parameters"]["inputSchema"]) + self.assertEqual(["echo"], schema["required"]) + self.assertIs(False, schema["additionalProperties"]) + + def test_the_served_tool_returns_a_string(self) -> None: + module = load_script() + tool = module.workflow_body(REFERENCE)["nodes"][1] + self.assertIn("JSON.stringify", tool["parameters"]["jsCode"]) + + def test_a_converged_deployment_reruns_unchanged(self) -> None: + module = load_script() + credential = {"id": "c9", "name": "infinito:mcp-bearer"} + api = FakeApi( + credentials=[credential], + workflows=[ + { + "id": "w1", + "name": "infinito:mcp-server", + "active": True, + "nodes": module.workflow_body(credential)["nodes"], + } + ], + ) + out = io.StringIO() + with patch.object(module, "call", api), contextlib.redirect_stdout(out): + module.main() + self.assertEqual("OK", out.getvalue().strip()) + + def test_resources_outside_the_managed_names_are_left_alone(self) -> None: + module = load_script() + api = FakeApi( + keys=[{"id": "human-key", "label": "someone else"}], + credentials=[{"id": "human-cred", "name": "someone else"}], + workflows=[{"id": "human-flow", "name": "someone else", "active": True}], + ) + with patch.object(module, "call", api): + module.main() + self.assertNotIn("human-key", api.deleted_keys) + self.assertEqual([], api.updated_workflows) + self.assertEqual( + ["infinito:mcp-server"], [flow["name"] for flow in api.created_workflows] + ) + self.assertEqual( + ["infinito:mcp-bearer"], [item["name"] for item in api.written_credentials] + ) + def test_two_managed_workflows_abort_rather_than_guess(self) -> None: module = load_script() api = FakeApi(