diff --git a/README.md b/README.md index bd4c654..cd08dff 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Examples that demonstrate how to model authorization for well-known products and |---------|-------------| | [Modeling Guide](./stores/modeling-guide) | Step-by-step introduction to OpenFGA modeling | | [Multi-Tenant RBAC](./stores/multitenant-rbac) | Multi-tenant role-based access control | +| [MCP Gateway](./stores/mcp-gateway) | Authorization for employee-facing and customer-facing multi-tenant MCP gateways | | [GitHub](./stores/github) | Repository, organization, and team permissions | | [Google Drive](./stores/gdrive) | File, folder, and shared drive permissions | | [Entitlements](./stores/entitlements) | Feature entitlements and plan-based access | diff --git a/stores/mcp-gateway/README.md b/stores/mcp-gateway/README.md new file mode 100644 index 0000000..4595d64 --- /dev/null +++ b/stores/mcp-gateway/README.md @@ -0,0 +1,59 @@ +# OpenFGA for MCP Gateways + +## Use Case + +This folder demonstrates three different models for MCP Gateway use cases: + +* **An employee-facing MCP Gateway:** Control which MCP server tools users can call through a gateway. Permissions are based on user identity and role membership. +* **A customer-facing multi-tenant MCP Gateway:** Where you write your own agents and want to control what the agents can do when interacting with each tenant's tools. Permissions are based on agent identity. +* **An intent-based authorization scenario:** An iteration of the previous one showing how to apply the same ideas for authorizing calls based on inferred intent. + +These examples depend on the experimental **Dynamic Conditions** feature, whose syntax, behavior, API, and tuple format may change. The FGA CLI enables inline expressions automatically when running model tests. Testing with an OpenFGA server requires OpenFGA v1.21.0 or later with the experimental feature enabled explicitly: + +```bash +openfga run --experimentals inline_expressions +``` + +## Employee Authorization + +**Requirements:** + +* **Gateway-level policies:** Allow definition of policies that are well-known to the gateway developer so they can be defined with static conditions. In the example, we add an IP restriction policy. +* **Broad tool access:** Allow access to all read-only or read-write tools per MCP server, user, or role. +* **Specific tool access:** Allow access to specific tools per user or role. +* **Parameter-based access:** Allow calling tools with specific parameter values per user or role. This is implemented using Dynamic Conditions. + +You can view the [mcp-gateway.fga.yaml](mcp-gateway.fga.yaml) example configuration. + +## Multi-Tenant Agent Authorization + +**Requirements:** + +* Agents operate on behalf of an organization. +* Agents can be granted permissions to call tools, including calls with specific parameter values. + +For example, you can configure that the `triage-bot` can only send Slack messages to `#product-announcements` for the organization `Acme`. + +You can view the [multi-tenant-mcp-gateway.fga.yaml](multi-tenant-mcp-gateway.fga.yaml) example configuration. + +## Intent-Based Authorization + +This builds on the previous example. Organization membership and tool ownership are persisted, but tool-call grants are supplied at request time instead of being persisted. + +**Requirements:** + +* Agents operate on behalf of an organization. +* Agents begin with no persisted tool-call grants. +* An intent inference engine (out of scope for this example) will infer which tools the agent should call and with which parameters. This information is sent to OpenFGA as contextual tuples. + +This pattern also applies to intent-based authorization. Permissions can come from an intent-inference engine or a token that grants the agent fine-grained contextual permissions. + +The model test writes its test-local `caller` tuples to a temporary datastore. A production application would instead include those grants as contextual tuples in its authorization check. + +You can view the [multi-tenant-mcp-gateway-intent.fga.yaml](multi-tenant-mcp-gateway-intent.fga.yaml) example configuration. + +## Try It Out + +1. Make sure you have the [FGA CLI v0.81 or later](https://github.com/openfga/cli/?tab=readme-ov-file#installation) installed. + +2. In the `mcp-gateway` directory, run `fga model test --tests .fga.yaml` diff --git a/stores/mcp-gateway/mcp-gateway.fga.yaml b/stores/mcp-gateway/mcp-gateway.fga.yaml new file mode 100644 index 0000000..08bc725 --- /dev/null +++ b/stores/mcp-gateway/mcp-gateway.fga.yaml @@ -0,0 +1,158 @@ +name: MCP Gateway for Workforce use cases +model: | + model + schema 1.1 + + type gateway + relations + # Gateway-level policies. + # If you write a user:* tuple, no gateway-level restrictions are applied + # This example restricts gateway access by IP using a condition defined in the model + define allow_policies : [user:*, user:* with in_company_network] + + type user + + type role + relations + define assignee : [user] + + type mcp_server + relations + # Adds the MCP server to the gateway instance so it inherits its policies + define gateway : [gateway] + + # Allows assigning access to all read-only or read-write tools + define readonly_access : [user, role#assignee] + define readwrite_access : [user, role#assignee] + + define can_call_readonly : (readonly_access or readwrite_access) and passes_gateway_policies + define can_call_readwrite : readwrite_access and passes_gateway_policies + define passes_gateway_policies: allow_policies from gateway + + type tool + relations + define mcp_server : [mcp_server] + # Tools without the read-only flag are treated as read-write + define is_readonly : [user:*] + + define caller : [user, role#assignee, role#assignee with $expression, user with $expression] + + define can_call : (caller and passes_gateway_policies from mcp_server) or (is_readonly and can_call_readonly from mcp_server) or can_call_readwrite from mcp_server + + condition in_company_network(user_ip: ipaddress, cidr: string) { + user_ip.in_cidr(cidr) + } + +tuples: + + ### Gateway level tuples + + # If you write this tuple, there won't be any network restriction conditions. + #- user: user:* + # relation: allow_policies + # object: gateway:mcp-gateway + + - user: user:* + relation: allow_policies + object: gateway:mcp-gateway + condition: + name: in_company_network + context: + cidr: '192.168.0.0/24' + + ### MCP configuration + - user: gateway:mcp-gateway + relation: gateway + object: mcp_server:slack + + ## These tuples are created from MCP server tool definitions + + # slack_read_channel has the 'readOnlyHint' hint in the MCP Tool definition + - user: user:* + relation: is_readonly + object: tool:slack_read_channel + + # The Slack MCP server owns the slack_send_message tool + - user: mcp_server:slack + relation: mcp_server + object: tool:slack_send_message + + # The Slack MCP server owns the slack_read_channel tool + - user: mcp_server:slack + relation: mcp_server + object: tool:slack_read_channel + + ### Users/Roles tuples + - user: user:anne + relation: assignee + object: role:devops + + - user: user:bob + relation: assignee + object: role:product + + - user: role:devops#assignee + relation: readwrite_access + object: mcp_server:slack + + # Assignees to the product role are the only ones that can post in product announcements + # Note that the developer building the MCP Gateway will need to allow users to enter + # these kinds of policies + + - user: role:product#assignee + relation: caller + object: tool:slack_send_message + condition: + name: $expression + context: + expression: "channel_name == '#product-announcements'" + parameters: + channel_name: string + +tests: + - name: dynamic-rbac-example + check: + # Anne has access as the devops team has write access to all channels + - user: user:anne + object: tool:slack_send_message + context: + channel_name: "#product-announcements" + user_ip: 192.168.0.1 + assertions: + can_call: true + + # Anne's read-write access also grants access to read-only tools + - user: user:anne + object: tool:slack_read_channel + context: + user_ip: 192.168.0.1 + assertions: + can_call: true + + # Bob has access to this channel as he is assigned to the product role + - user: user:bob + object: tool:slack_send_message + context: + channel_name: "#product-announcements" + user_ip: 192.168.0.1 + assertions: + can_call: true + + # Bob does not have access when using another IP + - user: user:bob + object: tool:slack_send_message + context: + channel_name: "#product-announcements" + user_ip: 200.168.0.1 + assertions: + can_call: false + + # Bob does not have access to channels outside the product announcements policy, even when on the correct IP + - user: user:bob + object: tool:slack_send_message + context: + channel_name: "#general" + user_ip: 192.168.0.1 + assertions: + can_call: false + diff --git a/stores/mcp-gateway/multi-tenant-mcp-gateway-intent.fga.yaml b/stores/mcp-gateway/multi-tenant-mcp-gateway-intent.fga.yaml new file mode 100644 index 0000000..ae89826 --- /dev/null +++ b/stores/mcp-gateway/multi-tenant-mcp-gateway-intent.fga.yaml @@ -0,0 +1,105 @@ +name: Customer-facing multi-tenant MCP Gateway with intent-based authorization +model: | + model + schema 1.1 + + type agent + + type organization + relations + define agent : [agent] + + type tool + relations + define organization : [organization] + define caller: [agent, agent with $expression] + define can_call: caller and agent from organization + +tuples: + - user: agent:triage-bot + relation: agent + object: organization:acme + + - user: agent:assistant + relation: agent + object: organization:acme + + # The Slack tools are enabled for the organization + - user: organization:acme + relation: organization + object: tool:acme_slack_send_message + + - user: organization:acme + relation: organization + object: tool:acme_slack_read_channel + + - user: organization:acme + relation: organization + object: tool:acme_slack_search_channels + + - user: organization:acme + relation: organization + object: tool:acme_slack_schedule_message + + # Agents start with zero permissions + +tests: + # We assume that the intent engine inferred the agent needs to find a specific + # Slack channel and post a message there. + # This is sent by the Intent engine as contextual tuples + + - name: test + tuples: + - user: agent:triage-bot + relation: caller + object: tool:acme_slack_send_message + condition: + name: $expression + context: + expression: "channel_name == '#product-announcements'" + parameters: + channel_name: string + + - user: agent:triage-bot + relation: caller + object: tool:acme_slack_search_channels + condition: + name: $expression + context: + expression: "query == 'product-announcements'" + parameters: + query: string + + check: + - user: agent:triage-bot + object: tool:acme_slack_search_channels + context: + query: "product-announcements" + assertions: + can_call: true + + - user: agent:triage-bot + object: tool:acme_slack_send_message + context: + channel_name: "#product-announcements" + assertions: + can_call: true + + - user: agent:triage-bot + object: tool:acme_slack_send_message + context: + channel_name: "#general" + assertions: + can_call: false + + - user: agent:triage-bot + object: tool:acme_slack_search_channels + context: + query: "general" + assertions: + can_call: false + + - user: agent:triage-bot + object: tool:acme_slack_schedule_message + assertions: + can_call: false \ No newline at end of file diff --git a/stores/mcp-gateway/multi-tenant-mcp-gateway.fga.yaml b/stores/mcp-gateway/multi-tenant-mcp-gateway.fga.yaml new file mode 100644 index 0000000..68170d7 --- /dev/null +++ b/stores/mcp-gateway/multi-tenant-mcp-gateway.fga.yaml @@ -0,0 +1,99 @@ +name: Customer-facing multi-tenant MCP Gateway +model: | + model + schema 1.1 + + type agent + + type organization + relations + define agent : [agent] + + type tool + relations + # The tool policies are per-organization + define organization : [organization] + define caller: [agent:*, agent, agent with $expression] + define can_call: caller and agent from organization + +tuples: + - user: agent:triage-bot + relation: agent + object: organization:acme + + - user: agent:assistant + relation: agent + object: organization:acme + + - user: agent:external + relation: agent + object: organization:globex + + # The Slack tools are enabled for the organization + - user: organization:acme + relation: organization + object: tool:acme_slack_send_message + + - user: organization:acme + relation: organization + object: tool:acme_slack_read_channel + + # Every agent in the acme organization can call acme_slack_read_channel. + - user: agent:* + relation: caller + object: tool:acme_slack_read_channel + + # The assistant agent can call acme_slack_send_message for every channel: + - user: agent:assistant + relation: caller + object: tool:acme_slack_send_message + + # The triage-bot agent can only use acme_slack_send_message for product-announcements + - user: agent:triage-bot + relation: caller + object: tool:acme_slack_send_message + condition: + name: $expression + context: + expression: "channel_name == '#product-announcements'" + parameters: + channel_name: string + +tests: + - name: dynamic-rbac-example + check: + # triage-bot can post to the channel allowed by its expression + - user: agent:triage-bot + object: tool:acme_slack_send_message + context: + channel_name: "#product-announcements" + assertions: + can_call: true + + # assistant has an unconditional grant, so any channel is allowed + - user: agent:assistant + object: tool:acme_slack_send_message + context: + channel_name: "#general" + assertions: + can_call: true + + # An agent from another organization cannot call Acme's tools + - user: agent:external + object: tool:acme_slack_read_channel + assertions: + can_call: false + + # triage-bot cannot post to channels outside its expression + - user: agent:triage-bot + object: tool:acme_slack_send_message + context: + channel_name: "#general" + assertions: + can_call: false + + # Channel context isn't needed because no conditional grant exists for this tool + - user: agent:triage-bot + object: tool:acme_slack_read_channel + assertions: + can_call: true \ No newline at end of file