Spaces:
Running
Running
svkrishna commited on
Commit ·
d817b84
1
Parent(s): 28c234f
Phase 1 : Pluggable Policy Engine — design an interface that allows policies to be loaded/unloaded at runtime; each policy should define machine-checkable invariants and decision logic.
Browse files- examples/policy_example.py +248 -0
- examples/smart_home/requirements.txt +3 -0
- requirements-dev.txt +33 -0
- requirements.txt +11 -0
- src/fastmcp/policy/__init__.py +8 -0
- src/fastmcp/policy/decision.py +60 -0
- src/fastmcp/policy/engine.py +137 -0
- src/fastmcp/policy/policies/__init__.py +6 -0
- src/fastmcp/policy/policies/minimum_necessary.py +144 -0
- src/fastmcp/policy/policies/rbac.py +233 -0
- src/fastmcp/policy/policy.py +63 -0
- src/fastmcp/policy/registry.py +189 -0
- src/fastmcp/server/policy_routes.py +87 -0
- src/fastmcp/server/server.py +34 -1
- tests/policy/__init__.py +1 -0
- tests/policy/test_policy_engine.py +333 -0
- tests/policy/test_policy_http.py +54 -0
examples/policy_example.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastMCP Policy Engine Example
|
| 3 |
+
|
| 4 |
+
This example demonstrates the pluggable policy engine with:
|
| 5 |
+
- Minimum Necessary Access Policy
|
| 6 |
+
- RBAC Policy
|
| 7 |
+
- Policy evaluation via HTTP endpoint
|
| 8 |
+
- Hot reloading of policies
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import json
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from fastmcp import FastMCP
|
| 16 |
+
from fastmcp.policy import PolicyEngine
|
| 17 |
+
from fastmcp.policy.policies import MinimumNecessaryAccessPolicy, RBACPolicy
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def create_policy_example():
|
| 21 |
+
"""Create a FastMCP server with policy engine enabled."""
|
| 22 |
+
|
| 23 |
+
# Create server
|
| 24 |
+
server = FastMCP("Policy Example Server")
|
| 25 |
+
|
| 26 |
+
# Enable policy engine
|
| 27 |
+
policy_engine = server.enable_policy_engine()
|
| 28 |
+
|
| 29 |
+
# Register built-in policies
|
| 30 |
+
policy_engine.register_policy(MinimumNecessaryAccessPolicy())
|
| 31 |
+
policy_engine.register_policy(RBACPolicy())
|
| 32 |
+
|
| 33 |
+
# Add a simple tool that demonstrates policy evaluation
|
| 34 |
+
@server.tool
|
| 35 |
+
async def access_resource(user_id: str, action: str, resource_type: str) -> dict:
|
| 36 |
+
"""Access a resource with policy evaluation."""
|
| 37 |
+
|
| 38 |
+
# Create context for policy evaluation
|
| 39 |
+
context = {
|
| 40 |
+
"user": {
|
| 41 |
+
"id": user_id,
|
| 42 |
+
"roles": ["user"] if user_id != "admin" else ["admin"],
|
| 43 |
+
"permissions": ["read", "write"] if user_id != "admin" else ["*"]
|
| 44 |
+
},
|
| 45 |
+
"action": action,
|
| 46 |
+
"resource": {
|
| 47 |
+
"type": resource_type,
|
| 48 |
+
"id": f"{resource_type}_123",
|
| 49 |
+
"owner": user_id,
|
| 50 |
+
"visibility": "private"
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
# Evaluate policies
|
| 55 |
+
decision = await policy_engine.evaluate(context)
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
"access_granted": decision.allow,
|
| 59 |
+
"reason": decision.reason,
|
| 60 |
+
"obligations": decision.obligations,
|
| 61 |
+
"proof": decision.proof
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return server
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
async def demonstrate_policy_evaluation():
|
| 68 |
+
"""Demonstrate policy evaluation with different scenarios."""
|
| 69 |
+
|
| 70 |
+
server = create_policy_example()
|
| 71 |
+
policy_engine = server.get_policy_engine()
|
| 72 |
+
assert policy_engine is not None # Ensure policy engine is enabled
|
| 73 |
+
|
| 74 |
+
print("🔐 FastMCP Policy Engine Example")
|
| 75 |
+
print("=" * 50)
|
| 76 |
+
|
| 77 |
+
# Test scenarios
|
| 78 |
+
scenarios = [
|
| 79 |
+
{
|
| 80 |
+
"name": "Regular user reading document",
|
| 81 |
+
"user_id": "user123",
|
| 82 |
+
"action": "read",
|
| 83 |
+
"resource_type": "document"
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"name": "Regular user deleting sensitive data",
|
| 87 |
+
"user_id": "user123",
|
| 88 |
+
"action": "delete",
|
| 89 |
+
"resource_type": "user_data"
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"name": "Admin accessing sensitive data",
|
| 93 |
+
"user_id": "admin",
|
| 94 |
+
"action": "delete",
|
| 95 |
+
"resource_type": "user_data"
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"name": "User without roles",
|
| 99 |
+
"user_id": "guest",
|
| 100 |
+
"action": "read",
|
| 101 |
+
"resource_type": "document"
|
| 102 |
+
}
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
for scenario in scenarios:
|
| 106 |
+
print(f"\n📋 Scenario: {scenario['name']}")
|
| 107 |
+
print("-" * 40)
|
| 108 |
+
|
| 109 |
+
# Create context
|
| 110 |
+
context = {
|
| 111 |
+
"user": {
|
| 112 |
+
"id": scenario["user_id"],
|
| 113 |
+
"roles": ["user"] if scenario["user_id"] != "admin" else ["admin"],
|
| 114 |
+
"permissions": ["read", "write"] if scenario["user_id"] != "admin" else ["*"]
|
| 115 |
+
},
|
| 116 |
+
"action": scenario["action"],
|
| 117 |
+
"resource": {
|
| 118 |
+
"type": scenario["resource_type"],
|
| 119 |
+
"id": f"{scenario['resource_type']}_123",
|
| 120 |
+
"owner": scenario["user_id"],
|
| 121 |
+
"visibility": "private"
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
# Evaluate policies
|
| 126 |
+
decision = await policy_engine.evaluate(context)
|
| 127 |
+
|
| 128 |
+
print(f"User: {scenario['user_id']}")
|
| 129 |
+
print(f"Action: {scenario['action']}")
|
| 130 |
+
print(f"Resource: {scenario['resource_type']}")
|
| 131 |
+
print(f"Decision: {'✅ ALLOW' if decision.allow else '❌ DENY'}")
|
| 132 |
+
print(f"Reason: {decision.reason}")
|
| 133 |
+
|
| 134 |
+
if decision.obligations:
|
| 135 |
+
print("Obligations:")
|
| 136 |
+
for obligation in decision.obligations:
|
| 137 |
+
print(f" - {obligation.get('type', 'unknown')}: {obligation.get('description', 'No description')}")
|
| 138 |
+
|
| 139 |
+
if decision.proof:
|
| 140 |
+
print(f"Proof: {json.dumps(decision.proof, indent=2)}")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def create_yaml_config():
|
| 144 |
+
"""Create a YAML configuration file for policies."""
|
| 145 |
+
|
| 146 |
+
config_content = """
|
| 147 |
+
policies:
|
| 148 |
+
- name: custom_minimum_necessary
|
| 149 |
+
type: minimum_necessary
|
| 150 |
+
parameters:
|
| 151 |
+
required_justification: true
|
| 152 |
+
sensitive_actions:
|
| 153 |
+
- "delete"
|
| 154 |
+
- "admin"
|
| 155 |
+
- "privileged"
|
| 156 |
+
sensitive_resources:
|
| 157 |
+
- "user_data"
|
| 158 |
+
- "financial"
|
| 159 |
+
- "medical"
|
| 160 |
+
|
| 161 |
+
- name: custom_rbac
|
| 162 |
+
type: rbac
|
| 163 |
+
parameters:
|
| 164 |
+
version: "1.0.0"
|
| 165 |
+
roles:
|
| 166 |
+
admin:
|
| 167 |
+
description: "Administrator with full access"
|
| 168 |
+
permissions: ["*"]
|
| 169 |
+
user:
|
| 170 |
+
description: "Regular user with basic access"
|
| 171 |
+
permissions: ["read", "write"]
|
| 172 |
+
guest:
|
| 173 |
+
description: "Guest user with read-only access"
|
| 174 |
+
permissions: ["read"]
|
| 175 |
+
"""
|
| 176 |
+
|
| 177 |
+
config_path = Path("policies.yaml")
|
| 178 |
+
config_path.write_text(config_content)
|
| 179 |
+
print(f"📄 Created YAML config: {config_path}")
|
| 180 |
+
return config_path
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
async def demonstrate_hot_reload():
|
| 184 |
+
"""Demonstrate hot reloading of policies."""
|
| 185 |
+
|
| 186 |
+
print("\n🔄 Hot Reload Demonstration")
|
| 187 |
+
print("=" * 50)
|
| 188 |
+
|
| 189 |
+
# Create server with policy engine
|
| 190 |
+
server = create_policy_example()
|
| 191 |
+
policy_engine = server.get_policy_engine()
|
| 192 |
+
assert policy_engine is not None # Ensure policy engine is enabled
|
| 193 |
+
|
| 194 |
+
# Register policy classes for YAML loading
|
| 195 |
+
policy_engine.registry.register_policy_class("minimum_necessary", MinimumNecessaryAccessPolicy)
|
| 196 |
+
policy_engine.registry.register_policy_class("rbac", RBACPolicy)
|
| 197 |
+
|
| 198 |
+
# Create YAML config
|
| 199 |
+
config_path = create_yaml_config()
|
| 200 |
+
|
| 201 |
+
# Load policies from YAML
|
| 202 |
+
print("Loading policies from YAML...")
|
| 203 |
+
policy_engine.registry.load_policies_from_yaml(config_path)
|
| 204 |
+
|
| 205 |
+
# List loaded policies
|
| 206 |
+
policies = policy_engine.get_policy_metadata()
|
| 207 |
+
print(f"Loaded {len(policies)} policies:")
|
| 208 |
+
for policy in policies:
|
| 209 |
+
print(f" - {policy['name']} ({policy['type']}) v{policy['version']}")
|
| 210 |
+
|
| 211 |
+
# Demonstrate hot reload
|
| 212 |
+
print("\nHot reloading policies...")
|
| 213 |
+
policy_engine.registry.hot_reload_policies(config_path)
|
| 214 |
+
|
| 215 |
+
# Clean up
|
| 216 |
+
config_path.unlink()
|
| 217 |
+
print("✅ Hot reload demonstration complete")
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def main():
|
| 221 |
+
"""Run the policy engine example."""
|
| 222 |
+
|
| 223 |
+
async def run_example():
|
| 224 |
+
# Demonstrate policy evaluation
|
| 225 |
+
await demonstrate_policy_evaluation()
|
| 226 |
+
|
| 227 |
+
# Demonstrate hot reload
|
| 228 |
+
await demonstrate_hot_reload()
|
| 229 |
+
|
| 230 |
+
print("\n🚀 Policy Engine Example Complete!")
|
| 231 |
+
print("\nTo test the HTTP endpoint:")
|
| 232 |
+
print("1. Run: fastmcp run examples/policy_example.py --transport http")
|
| 233 |
+
print("2. POST to /policy/evaluate with JSON body:")
|
| 234 |
+
print("""
|
| 235 |
+
{
|
| 236 |
+
"context": {
|
| 237 |
+
"user": {"id": "user123", "roles": ["user"]},
|
| 238 |
+
"action": "read",
|
| 239 |
+
"resource": {"type": "document", "id": "doc123"}
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
""")
|
| 243 |
+
|
| 244 |
+
asyncio.run(run_example())
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
if __name__ == "__main__":
|
| 248 |
+
main()
|
examples/smart_home/requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastmcp@git+https://github.com/jlowin/fastmcp.git
|
| 2 |
+
phue2
|
| 3 |
+
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core dependencies
|
| 2 |
+
python-dotenv>=1.1.0
|
| 3 |
+
exceptiongroup>=1.2.2
|
| 4 |
+
httpx>=0.28.1
|
| 5 |
+
mcp>=1.12.4,<2.0.0
|
| 6 |
+
openapi-pydantic>=0.5.1
|
| 7 |
+
rich>=13.9.4
|
| 8 |
+
cyclopts>=3.0.0
|
| 9 |
+
authlib>=1.5.2
|
| 10 |
+
pydantic[email]>=2.11.7
|
| 11 |
+
pyperclip>=1.9.0
|
| 12 |
+
openapi-core>=0.19.5
|
| 13 |
+
|
| 14 |
+
# Development dependencies
|
| 15 |
+
copychat>=0.5.2
|
| 16 |
+
dirty-equals>=0.9.0
|
| 17 |
+
fastapi>=0.115.12
|
| 18 |
+
ipython>=8.12.3
|
| 19 |
+
pdbpp>=0.10.3
|
| 20 |
+
pre-commit
|
| 21 |
+
psutil
|
| 22 |
+
pyinstrument>=5.0.2
|
| 23 |
+
pyright>=1.1.389
|
| 24 |
+
pytest>=8.3.3
|
| 25 |
+
pytest-asyncio>=0.23.5
|
| 26 |
+
pytest-cov>=6.1.1
|
| 27 |
+
pytest-env>=1.1.5
|
| 28 |
+
pytest-flakefinder
|
| 29 |
+
pytest-httpx>=0.35.0
|
| 30 |
+
pytest-report>=0.2.1
|
| 31 |
+
pytest-timeout>=2.4.0
|
| 32 |
+
pytest-xdist>=3.6.1
|
| 33 |
+
ruff
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
python-dotenv>=1.1.0
|
| 2 |
+
exceptiongroup>=1.2.2
|
| 3 |
+
httpx>=0.28.1
|
| 4 |
+
mcp>=1.12.4,<2.0.0
|
| 5 |
+
openapi-pydantic>=0.5.1
|
| 6 |
+
rich>=13.9.4
|
| 7 |
+
cyclopts>=3.0.0
|
| 8 |
+
authlib>=1.5.2
|
| 9 |
+
pydantic[email]>=2.11.7
|
| 10 |
+
pyperclip>=1.9.0
|
| 11 |
+
openapi-core>=0.19.5
|
src/fastmcp/policy/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastMCP Policy Engine - Pluggable authorization and access control."""
|
| 2 |
+
|
| 3 |
+
from .decision import Decision
|
| 4 |
+
from .engine import PolicyEngine
|
| 5 |
+
from .policy import Policy
|
| 6 |
+
from .registry import PolicyRegistry
|
| 7 |
+
|
| 8 |
+
__all__ = ["Policy", "PolicyEngine", "PolicyRegistry", "Decision"]
|
src/fastmcp/policy/decision.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Policy decision result types."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class Decision:
|
| 9 |
+
"""Result of a policy evaluation."""
|
| 10 |
+
|
| 11 |
+
allow: bool
|
| 12 |
+
"""Whether the action is allowed."""
|
| 13 |
+
|
| 14 |
+
obligations: List[Dict[str, Any]]
|
| 15 |
+
"""List of obligations that must be fulfilled."""
|
| 16 |
+
|
| 17 |
+
reason: str
|
| 18 |
+
"""Human-readable reason for the decision."""
|
| 19 |
+
|
| 20 |
+
proof: Optional[Dict[str, Any]] = None
|
| 21 |
+
"""Optional proof or evidence for the decision."""
|
| 22 |
+
|
| 23 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 24 |
+
"""Convert decision to dictionary for JSON serialization."""
|
| 25 |
+
return {
|
| 26 |
+
"allow": self.allow,
|
| 27 |
+
"obligations": self.obligations,
|
| 28 |
+
"reason": self.reason,
|
| 29 |
+
"proof": self.proof,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
@classmethod
|
| 33 |
+
def allow_decision(
|
| 34 |
+
cls,
|
| 35 |
+
reason: str = "Access granted",
|
| 36 |
+
obligations: Optional[List[Dict[str, Any]]] = None,
|
| 37 |
+
proof: Optional[Dict[str, Any]] = None
|
| 38 |
+
) -> "Decision":
|
| 39 |
+
"""Create an allow decision."""
|
| 40 |
+
return cls(
|
| 41 |
+
allow=True,
|
| 42 |
+
obligations=obligations or [],
|
| 43 |
+
reason=reason,
|
| 44 |
+
proof=proof,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
@classmethod
|
| 48 |
+
def deny_decision(
|
| 49 |
+
cls,
|
| 50 |
+
reason: str = "Access denied",
|
| 51 |
+
obligations: Optional[List[Dict[str, Any]]] = None,
|
| 52 |
+
proof: Optional[Dict[str, Any]] = None
|
| 53 |
+
) -> "Decision":
|
| 54 |
+
"""Create a deny decision."""
|
| 55 |
+
return cls(
|
| 56 |
+
allow=False,
|
| 57 |
+
obligations=obligations or [],
|
| 58 |
+
reason=reason,
|
| 59 |
+
proof=proof,
|
| 60 |
+
)
|
src/fastmcp/policy/engine.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Policy engine for coordinating policy evaluation."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from fastmcp.utilities.logging import get_logger
|
| 6 |
+
|
| 7 |
+
from .decision import Decision
|
| 8 |
+
from .policy import Policy, PolicyContext
|
| 9 |
+
from .registry import PolicyRegistry
|
| 10 |
+
|
| 11 |
+
logger = get_logger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class PolicyEngine:
|
| 15 |
+
"""Engine for evaluating policies."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, registry: Optional[PolicyRegistry] = None):
|
| 18 |
+
self.registry = registry or PolicyRegistry()
|
| 19 |
+
self._evaluation_order: List[str] = []
|
| 20 |
+
|
| 21 |
+
def set_evaluation_order(self, policy_names: List[str]) -> None:
|
| 22 |
+
"""Set the order in which policies should be evaluated.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
policy_names: List of policy names in evaluation order
|
| 26 |
+
"""
|
| 27 |
+
self._evaluation_order = policy_names.copy()
|
| 28 |
+
logger.info(f"Set policy evaluation order: {policy_names}")
|
| 29 |
+
|
| 30 |
+
async def evaluate(
|
| 31 |
+
self,
|
| 32 |
+
context: Dict[str, Any],
|
| 33 |
+
policy_names: Optional[List[str]] = None
|
| 34 |
+
) -> Decision:
|
| 35 |
+
"""Evaluate policies against the given context.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
context: The context for evaluation
|
| 39 |
+
policy_names: Optional list of policy names to evaluate.
|
| 40 |
+
If None, evaluates all registered policies.
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
The final decision after evaluating all policies
|
| 44 |
+
"""
|
| 45 |
+
if policy_names is None:
|
| 46 |
+
# Use evaluation order if set, otherwise use all policies
|
| 47 |
+
if self._evaluation_order:
|
| 48 |
+
policy_names = self._evaluation_order
|
| 49 |
+
else:
|
| 50 |
+
policy_names = list(self.registry._policies.keys())
|
| 51 |
+
|
| 52 |
+
logger.debug(f"Evaluating policies: {policy_names}")
|
| 53 |
+
|
| 54 |
+
# Evaluate each policy in order
|
| 55 |
+
for policy_name in policy_names:
|
| 56 |
+
policy = self.registry.get_policy(policy_name)
|
| 57 |
+
if not policy:
|
| 58 |
+
logger.warning(f"Policy not found: {policy_name}")
|
| 59 |
+
continue
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
decision = await policy.evaluate(context)
|
| 63 |
+
logger.debug(f"Policy {policy_name} decision: {decision.allow} - {decision.reason}")
|
| 64 |
+
|
| 65 |
+
# If any policy denies, return deny decision
|
| 66 |
+
if not decision.allow:
|
| 67 |
+
return decision
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"Error evaluating policy {policy_name}: {e}")
|
| 71 |
+
return Decision.deny_decision(
|
| 72 |
+
reason=f"Policy evaluation error: {e}",
|
| 73 |
+
proof={"policy": policy_name, "error": str(e)}
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# All policies allowed
|
| 77 |
+
return Decision.allow_decision(
|
| 78 |
+
reason="All policies evaluated successfully",
|
| 79 |
+
proof={"evaluated_policies": policy_names}
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
async def evaluate_single_policy(
|
| 83 |
+
self,
|
| 84 |
+
policy_name: str,
|
| 85 |
+
context: Dict[str, Any]
|
| 86 |
+
) -> Optional[Decision]:
|
| 87 |
+
"""Evaluate a single policy.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
policy_name: The name of the policy to evaluate
|
| 91 |
+
context: The context for evaluation
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
The decision, or None if policy not found
|
| 95 |
+
"""
|
| 96 |
+
policy = self.registry.get_policy(policy_name)
|
| 97 |
+
if not policy:
|
| 98 |
+
logger.warning(f"Policy not found: {policy_name}")
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
decision = await policy.evaluate(context)
|
| 103 |
+
logger.debug(f"Single policy {policy_name} decision: {decision.allow} - {decision.reason}")
|
| 104 |
+
return decision
|
| 105 |
+
except Exception as e:
|
| 106 |
+
logger.error(f"Error evaluating policy {policy_name}: {e}")
|
| 107 |
+
return Decision.deny_decision(
|
| 108 |
+
reason=f"Policy evaluation error: {e}",
|
| 109 |
+
proof={"policy": policy_name, "error": str(e)}
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
def get_policy_metadata(self) -> List[Dict[str, Any]]:
|
| 113 |
+
"""Get metadata for all registered policies.
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
List of policy metadata dictionaries
|
| 117 |
+
"""
|
| 118 |
+
return self.registry.list_policies()
|
| 119 |
+
|
| 120 |
+
def register_policy(self, policy: Policy) -> None:
|
| 121 |
+
"""Register a policy with the engine.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
policy: The policy to register
|
| 125 |
+
"""
|
| 126 |
+
self.registry.register_policy(policy)
|
| 127 |
+
|
| 128 |
+
def unregister_policy(self, name: str) -> Optional[Policy]:
|
| 129 |
+
"""Unregister a policy from the engine.
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
name: The name of the policy to unregister
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
The unregistered policy, or None if not found
|
| 136 |
+
"""
|
| 137 |
+
return self.registry.unregister_policy(name)
|
src/fastmcp/policy/policies/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Built-in policy implementations."""
|
| 2 |
+
|
| 3 |
+
from .minimum_necessary import MinimumNecessaryAccessPolicy
|
| 4 |
+
from .rbac import RBACPolicy
|
| 5 |
+
|
| 6 |
+
__all__ = ["MinimumNecessaryAccessPolicy", "RBACPolicy"]
|
src/fastmcp/policy/policies/minimum_necessary.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimum Necessary Access Policy implementation."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Set, Optional
|
| 4 |
+
|
| 5 |
+
from ..policy import Policy
|
| 6 |
+
from ..decision import Decision
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class MinimumNecessaryAccessPolicy(Policy):
|
| 10 |
+
"""Policy that enforces minimum necessary access principles."""
|
| 11 |
+
|
| 12 |
+
def __init__(
|
| 13 |
+
self,
|
| 14 |
+
name: str = "minimum_necessary_access",
|
| 15 |
+
version: str = "1.0.0",
|
| 16 |
+
sensitive_actions: Optional[List[str]] = None,
|
| 17 |
+
sensitive_resources: Optional[List[str]] = None,
|
| 18 |
+
required_justification: bool = True
|
| 19 |
+
):
|
| 20 |
+
super().__init__(name, version)
|
| 21 |
+
self.sensitive_actions = set(sensitive_actions or [
|
| 22 |
+
"delete", "modify", "admin", "root", "sudo", "privileged"
|
| 23 |
+
])
|
| 24 |
+
self.sensitive_resources = set(sensitive_resources or [
|
| 25 |
+
"user_data", "financial", "medical", "personal", "confidential"
|
| 26 |
+
])
|
| 27 |
+
self.required_justification = required_justification
|
| 28 |
+
|
| 29 |
+
async def evaluate(self, context: Dict[str, Any]) -> Decision:
|
| 30 |
+
"""Evaluate minimum necessary access policy.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
context: The context containing user, action, resource information
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
Decision indicating whether access is allowed
|
| 37 |
+
"""
|
| 38 |
+
user = context.get("user", {})
|
| 39 |
+
action = context.get("action", "")
|
| 40 |
+
resource = context.get("resource", {})
|
| 41 |
+
|
| 42 |
+
# Check if action is sensitive
|
| 43 |
+
action_lower = action.lower()
|
| 44 |
+
is_sensitive_action = any(
|
| 45 |
+
sensitive in action_lower for sensitive in self.sensitive_actions
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Check if resource is sensitive
|
| 49 |
+
resource_type = resource.get("type", "")
|
| 50 |
+
resource_tags = resource.get("tags", [])
|
| 51 |
+
is_sensitive_resource = (
|
| 52 |
+
resource_type in self.sensitive_resources or
|
| 53 |
+
any(tag in self.sensitive_resources for tag in resource_tags)
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# If neither action nor resource is sensitive, allow
|
| 57 |
+
if not is_sensitive_action and not is_sensitive_resource:
|
| 58 |
+
return Decision.allow_decision(
|
| 59 |
+
reason="Action and resource are not sensitive",
|
| 60 |
+
proof={
|
| 61 |
+
"action_sensitive": False,
|
| 62 |
+
"resource_sensitive": False
|
| 63 |
+
}
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Check for justification if required
|
| 67 |
+
if self.required_justification:
|
| 68 |
+
justification = context.get("justification", "")
|
| 69 |
+
if not justification or len(justification.strip()) < 10:
|
| 70 |
+
return Decision.deny_decision(
|
| 71 |
+
reason="Sensitive operation requires justification",
|
| 72 |
+
obligations=[
|
| 73 |
+
{
|
| 74 |
+
"type": "provide_justification",
|
| 75 |
+
"description": "Provide a detailed justification for this sensitive operation"
|
| 76 |
+
}
|
| 77 |
+
],
|
| 78 |
+
proof={
|
| 79 |
+
"action_sensitive": is_sensitive_action,
|
| 80 |
+
"resource_sensitive": is_sensitive_resource,
|
| 81 |
+
"justification_provided": bool(justification),
|
| 82 |
+
"justification_length": len(justification) if justification else 0
|
| 83 |
+
}
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Check user permissions
|
| 87 |
+
user_roles = user.get("roles", [])
|
| 88 |
+
user_permissions = user.get("permissions", [])
|
| 89 |
+
|
| 90 |
+
# Allow if user has explicit permission
|
| 91 |
+
if "admin" in user_roles or "privileged" in user_permissions:
|
| 92 |
+
return Decision.allow_decision(
|
| 93 |
+
reason="User has privileged access",
|
| 94 |
+
obligations=[
|
| 95 |
+
{
|
| 96 |
+
"type": "audit_log",
|
| 97 |
+
"description": "Log this sensitive operation for audit purposes"
|
| 98 |
+
}
|
| 99 |
+
],
|
| 100 |
+
proof={
|
| 101 |
+
"user_roles": user_roles,
|
| 102 |
+
"user_permissions": user_permissions,
|
| 103 |
+
"action_sensitive": is_sensitive_action,
|
| 104 |
+
"resource_sensitive": is_sensitive_resource
|
| 105 |
+
}
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# Check for time-based restrictions
|
| 109 |
+
time_context = context.get("time", {})
|
| 110 |
+
current_hour = time_context.get("hour", 0)
|
| 111 |
+
|
| 112 |
+
# Restrict sensitive operations during off-hours (example: 10 PM to 6 AM)
|
| 113 |
+
if is_sensitive_action and (current_hour >= 22 or current_hour < 6):
|
| 114 |
+
return Decision.deny_decision(
|
| 115 |
+
reason="Sensitive operations restricted during off-hours",
|
| 116 |
+
obligations=[
|
| 117 |
+
{
|
| 118 |
+
"type": "schedule_operation",
|
| 119 |
+
"description": "Schedule this operation during business hours"
|
| 120 |
+
}
|
| 121 |
+
],
|
| 122 |
+
proof={
|
| 123 |
+
"current_hour": current_hour,
|
| 124 |
+
"off_hours": True,
|
| 125 |
+
"action_sensitive": True
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
# Default deny for sensitive operations without proper authorization
|
| 130 |
+
return Decision.deny_decision(
|
| 131 |
+
reason="Insufficient permissions for sensitive operation",
|
| 132 |
+
obligations=[
|
| 133 |
+
{
|
| 134 |
+
"type": "request_approval",
|
| 135 |
+
"description": "Request approval from administrator"
|
| 136 |
+
}
|
| 137 |
+
],
|
| 138 |
+
proof={
|
| 139 |
+
"action_sensitive": is_sensitive_action,
|
| 140 |
+
"resource_sensitive": is_sensitive_resource,
|
| 141 |
+
"user_roles": user_roles,
|
| 142 |
+
"user_permissions": user_permissions
|
| 143 |
+
}
|
| 144 |
+
)
|
src/fastmcp/policy/policies/rbac.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Role-Based Access Control (RBAC) Policy implementation."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Optional, Set
|
| 4 |
+
|
| 5 |
+
from ..policy import Policy
|
| 6 |
+
from ..decision import Decision
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class RBACPolicy(Policy):
|
| 10 |
+
"""Policy that implements Role-Based Access Control."""
|
| 11 |
+
|
| 12 |
+
def __init__(
|
| 13 |
+
self,
|
| 14 |
+
name: str = "rbac",
|
| 15 |
+
version: str = "1.0.0",
|
| 16 |
+
roles: Optional[Dict[str, Dict[str, Any]]] = None,
|
| 17 |
+
permissions: Optional[Dict[str, List[str]]] = None,
|
| 18 |
+
role_hierarchy: Optional[Dict[str, List[str]]] = None
|
| 19 |
+
):
|
| 20 |
+
super().__init__(name, version)
|
| 21 |
+
|
| 22 |
+
# Default roles and permissions
|
| 23 |
+
self.roles = roles or {
|
| 24 |
+
"admin": {
|
| 25 |
+
"description": "Administrator with full access",
|
| 26 |
+
"permissions": ["*"]
|
| 27 |
+
},
|
| 28 |
+
"user": {
|
| 29 |
+
"description": "Regular user with basic access",
|
| 30 |
+
"permissions": ["read", "write"]
|
| 31 |
+
},
|
| 32 |
+
"guest": {
|
| 33 |
+
"description": "Guest user with read-only access",
|
| 34 |
+
"permissions": ["read"]
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
# Custom permissions mapping
|
| 39 |
+
self.permissions = permissions or {
|
| 40 |
+
"read": ["get", "list", "view", "read"],
|
| 41 |
+
"write": ["create", "update", "modify", "write"],
|
| 42 |
+
"delete": ["remove", "delete", "destroy"],
|
| 43 |
+
"admin": ["admin", "manage", "configure", "privileged"]
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
# Role hierarchy (inheritance)
|
| 47 |
+
self.role_hierarchy = role_hierarchy or {
|
| 48 |
+
"admin": ["user", "guest"],
|
| 49 |
+
"user": ["guest"]
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
def _get_user_permissions(self, user_roles: List[str]) -> Set[str]:
|
| 53 |
+
"""Get all permissions for a user based on their roles and hierarchy.
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
user_roles: List of user roles
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
Set of all permissions the user has
|
| 60 |
+
"""
|
| 61 |
+
all_permissions = set()
|
| 62 |
+
|
| 63 |
+
for role in user_roles:
|
| 64 |
+
if role not in self.roles:
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
# Get direct permissions for this role
|
| 68 |
+
role_permissions = self.roles[role].get("permissions", [])
|
| 69 |
+
if "*" in role_permissions:
|
| 70 |
+
# Wildcard permission - user has all permissions
|
| 71 |
+
return {"*"}
|
| 72 |
+
|
| 73 |
+
all_permissions.update(role_permissions)
|
| 74 |
+
|
| 75 |
+
# Get inherited permissions from role hierarchy
|
| 76 |
+
inherited_roles = self.role_hierarchy.get(role, [])
|
| 77 |
+
for inherited_role in inherited_roles:
|
| 78 |
+
if inherited_role in self.roles:
|
| 79 |
+
inherited_permissions = self.roles[inherited_role].get("permissions", [])
|
| 80 |
+
all_permissions.update(inherited_permissions)
|
| 81 |
+
|
| 82 |
+
return all_permissions
|
| 83 |
+
|
| 84 |
+
def _check_permission(self, user_permissions: Set[str], required_action: str) -> bool:
|
| 85 |
+
"""Check if user has permission for the required action.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
user_permissions: Set of user permissions
|
| 89 |
+
required_action: The action being performed
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
True if user has permission, False otherwise
|
| 93 |
+
"""
|
| 94 |
+
# Check for wildcard permission
|
| 95 |
+
if "*" in user_permissions:
|
| 96 |
+
return True
|
| 97 |
+
|
| 98 |
+
# Check direct permission match
|
| 99 |
+
if required_action in user_permissions:
|
| 100 |
+
return True
|
| 101 |
+
|
| 102 |
+
# Check permission mappings
|
| 103 |
+
for permission, actions in self.permissions.items():
|
| 104 |
+
if permission in user_permissions and required_action in actions:
|
| 105 |
+
return True
|
| 106 |
+
|
| 107 |
+
return False
|
| 108 |
+
|
| 109 |
+
async def evaluate(self, context: Dict[str, Any]) -> Decision:
|
| 110 |
+
"""Evaluate RBAC policy.
|
| 111 |
+
|
| 112 |
+
Args:
|
| 113 |
+
context: The context containing user, action, resource information
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
Decision indicating whether access is allowed
|
| 117 |
+
"""
|
| 118 |
+
user = context.get("user", {})
|
| 119 |
+
action = context.get("action", "")
|
| 120 |
+
resource = context.get("resource", {})
|
| 121 |
+
|
| 122 |
+
# Get user roles
|
| 123 |
+
user_roles = user.get("roles", [])
|
| 124 |
+
if not user_roles:
|
| 125 |
+
return Decision.deny_decision(
|
| 126 |
+
reason="User has no assigned roles",
|
| 127 |
+
proof={
|
| 128 |
+
"user_roles": user_roles,
|
| 129 |
+
"action": action
|
| 130 |
+
}
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
# Get all user permissions
|
| 134 |
+
user_permissions = self._get_user_permissions(user_roles)
|
| 135 |
+
|
| 136 |
+
# Check if user has permission for the action
|
| 137 |
+
has_permission = self._check_permission(user_permissions, action)
|
| 138 |
+
|
| 139 |
+
if has_permission:
|
| 140 |
+
# Check resource-specific restrictions
|
| 141 |
+
resource_type = resource.get("type", "")
|
| 142 |
+
resource_owner = resource.get("owner", "")
|
| 143 |
+
user_id = user.get("id", "")
|
| 144 |
+
|
| 145 |
+
# Allow if user owns the resource or has admin role
|
| 146 |
+
if (resource_owner == user_id or
|
| 147 |
+
"admin" in user_roles or
|
| 148 |
+
"*" in user_permissions):
|
| 149 |
+
return Decision.allow_decision(
|
| 150 |
+
reason="User has permission and owns resource or is admin",
|
| 151 |
+
obligations=[
|
| 152 |
+
{
|
| 153 |
+
"type": "audit_log",
|
| 154 |
+
"description": "Log this RBAC-authorized operation"
|
| 155 |
+
}
|
| 156 |
+
],
|
| 157 |
+
proof={
|
| 158 |
+
"user_roles": user_roles,
|
| 159 |
+
"user_permissions": list(user_permissions),
|
| 160 |
+
"action": action,
|
| 161 |
+
"resource_owner": resource_owner,
|
| 162 |
+
"user_id": user_id,
|
| 163 |
+
"permission_check": True
|
| 164 |
+
}
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# Check if resource is public/shared
|
| 168 |
+
resource_visibility = resource.get("visibility", "private")
|
| 169 |
+
if resource_visibility in ["public", "shared"]:
|
| 170 |
+
return Decision.allow_decision(
|
| 171 |
+
reason="User has permission and resource is public/shared",
|
| 172 |
+
proof={
|
| 173 |
+
"user_roles": user_roles,
|
| 174 |
+
"user_permissions": list(user_permissions),
|
| 175 |
+
"action": action,
|
| 176 |
+
"resource_visibility": resource_visibility,
|
| 177 |
+
"permission_check": True
|
| 178 |
+
}
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Check for explicit resource permissions
|
| 182 |
+
resource_permissions = resource.get("permissions", {})
|
| 183 |
+
if user_id in resource_permissions:
|
| 184 |
+
user_resource_permissions = resource_permissions[user_id]
|
| 185 |
+
if action in user_resource_permissions or "*" in user_resource_permissions:
|
| 186 |
+
return Decision.allow_decision(
|
| 187 |
+
reason="User has explicit resource permission",
|
| 188 |
+
proof={
|
| 189 |
+
"user_roles": user_roles,
|
| 190 |
+
"user_permissions": list(user_permissions),
|
| 191 |
+
"action": action,
|
| 192 |
+
"resource_permissions": user_resource_permissions,
|
| 193 |
+
"permission_check": True
|
| 194 |
+
}
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# Deny access to private resource without ownership
|
| 198 |
+
return Decision.deny_decision(
|
| 199 |
+
reason="User lacks permission for this private resource",
|
| 200 |
+
obligations=[
|
| 201 |
+
{
|
| 202 |
+
"type": "request_access",
|
| 203 |
+
"description": "Request access from resource owner"
|
| 204 |
+
}
|
| 205 |
+
],
|
| 206 |
+
proof={
|
| 207 |
+
"user_roles": user_roles,
|
| 208 |
+
"user_permissions": list(user_permissions),
|
| 209 |
+
"action": action,
|
| 210 |
+
"resource_owner": resource_owner,
|
| 211 |
+
"user_id": user_id,
|
| 212 |
+
"resource_visibility": resource_visibility,
|
| 213 |
+
"permission_check": True,
|
| 214 |
+
"ownership_check": False
|
| 215 |
+
}
|
| 216 |
+
)
|
| 217 |
+
else:
|
| 218 |
+
# User doesn't have permission for the action
|
| 219 |
+
return Decision.deny_decision(
|
| 220 |
+
reason="User lacks permission for this action",
|
| 221 |
+
obligations=[
|
| 222 |
+
{
|
| 223 |
+
"type": "request_permission",
|
| 224 |
+
"description": "Request permission from administrator"
|
| 225 |
+
}
|
| 226 |
+
],
|
| 227 |
+
proof={
|
| 228 |
+
"user_roles": user_roles,
|
| 229 |
+
"user_permissions": list(user_permissions),
|
| 230 |
+
"action": action,
|
| 231 |
+
"permission_check": False
|
| 232 |
+
}
|
| 233 |
+
)
|
src/fastmcp/policy/policy.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Policy interface and base classes."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from typing import Any, Dict, Optional
|
| 5 |
+
|
| 6 |
+
from .decision import Decision
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Policy(ABC):
|
| 10 |
+
"""Base class for all policies."""
|
| 11 |
+
|
| 12 |
+
def __init__(self, name: str, version: str = "1.0.0"):
|
| 13 |
+
self.name = name
|
| 14 |
+
self.version = version
|
| 15 |
+
|
| 16 |
+
@abstractmethod
|
| 17 |
+
async def evaluate(self, context: Dict[str, Any]) -> Decision:
|
| 18 |
+
"""Evaluate the policy against the given context.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
context: The context containing information about the request,
|
| 22 |
+
user, resource, action, etc.
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
A Decision object indicating whether access is allowed or denied.
|
| 26 |
+
"""
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
def get_metadata(self) -> Dict[str, Any]:
|
| 30 |
+
"""Get policy metadata."""
|
| 31 |
+
return {
|
| 32 |
+
"name": self.name,
|
| 33 |
+
"version": self.version,
|
| 34 |
+
"type": self.__class__.__name__,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class PolicyContext:
|
| 39 |
+
"""Context for policy evaluation."""
|
| 40 |
+
|
| 41 |
+
def __init__(
|
| 42 |
+
self,
|
| 43 |
+
user: Optional[Dict[str, Any]] = None,
|
| 44 |
+
resource: Optional[Dict[str, Any]] = None,
|
| 45 |
+
action: Optional[str] = None,
|
| 46 |
+
environment: Optional[Dict[str, Any]] = None,
|
| 47 |
+
**kwargs
|
| 48 |
+
):
|
| 49 |
+
self.user = user or {}
|
| 50 |
+
self.resource = resource or {}
|
| 51 |
+
self.action = action
|
| 52 |
+
self.environment = environment or {}
|
| 53 |
+
self.extra = kwargs
|
| 54 |
+
|
| 55 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 56 |
+
"""Convert context to dictionary."""
|
| 57 |
+
return {
|
| 58 |
+
"user": self.user,
|
| 59 |
+
"resource": self.resource,
|
| 60 |
+
"action": self.action,
|
| 61 |
+
"environment": self.environment,
|
| 62 |
+
**self.extra,
|
| 63 |
+
}
|
src/fastmcp/policy/registry.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Policy registry for managing policies at runtime."""
|
| 2 |
+
|
| 3 |
+
import importlib
|
| 4 |
+
import importlib.metadata
|
| 5 |
+
import json
|
| 6 |
+
import yaml
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Dict, List, Optional, Type
|
| 9 |
+
|
| 10 |
+
from fastmcp.utilities.logging import get_logger
|
| 11 |
+
|
| 12 |
+
from .policy import Policy
|
| 13 |
+
|
| 14 |
+
logger = get_logger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class PolicyRegistry:
|
| 18 |
+
"""Registry for managing policies at runtime."""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
self._policies: Dict[str, Policy] = {}
|
| 22 |
+
self._policy_classes: Dict[str, Type[Policy]] = {}
|
| 23 |
+
|
| 24 |
+
def register_policy(self, policy: Policy) -> None:
|
| 25 |
+
"""Register a policy instance.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
policy: The policy instance to register
|
| 29 |
+
"""
|
| 30 |
+
self._policies[policy.name] = policy
|
| 31 |
+
logger.info(f"Registered policy: {policy.name} v{policy.version}")
|
| 32 |
+
|
| 33 |
+
def unregister_policy(self, name: str) -> Optional[Policy]:
|
| 34 |
+
"""Unregister a policy by name.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
name: The name of the policy to unregister
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
The unregistered policy, or None if not found
|
| 41 |
+
"""
|
| 42 |
+
policy = self._policies.pop(name, None)
|
| 43 |
+
if policy:
|
| 44 |
+
logger.info(f"Unregistered policy: {name}")
|
| 45 |
+
return policy
|
| 46 |
+
|
| 47 |
+
def get_policy(self, name: str) -> Optional[Policy]:
|
| 48 |
+
"""Get a policy by name.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
name: The name of the policy
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
The policy instance, or None if not found
|
| 55 |
+
"""
|
| 56 |
+
return self._policies.get(name)
|
| 57 |
+
|
| 58 |
+
def list_policies(self) -> List[Dict[str, Any]]:
|
| 59 |
+
"""List all registered policies.
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
List of policy metadata dictionaries
|
| 63 |
+
"""
|
| 64 |
+
return [policy.get_metadata() for policy in self._policies.values()]
|
| 65 |
+
|
| 66 |
+
def load_policy_from_entry_point(self, entry_point_name: str) -> None:
|
| 67 |
+
"""Load policies from entry points.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
entry_point_name: The entry point name to load from
|
| 71 |
+
"""
|
| 72 |
+
try:
|
| 73 |
+
entry_points = importlib.metadata.entry_points()
|
| 74 |
+
if hasattr(entry_points, 'select'):
|
| 75 |
+
# Python 3.10+
|
| 76 |
+
policy_entry_points = entry_points.select(group=entry_point_name)
|
| 77 |
+
else:
|
| 78 |
+
# Python 3.8-3.9
|
| 79 |
+
policy_entry_points = entry_points.get(entry_point_name, [])
|
| 80 |
+
|
| 81 |
+
for entry_point in policy_entry_points:
|
| 82 |
+
try:
|
| 83 |
+
policy_class = entry_point.load()
|
| 84 |
+
if issubclass(policy_class, Policy):
|
| 85 |
+
# Create policy with default name from entry point
|
| 86 |
+
policy = policy_class(name=entry_point.name)
|
| 87 |
+
self.register_policy(policy)
|
| 88 |
+
else:
|
| 89 |
+
logger.warning(f"Entry point {entry_point.name} does not return a Policy class")
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"Failed to load policy from entry point {entry_point.name}: {e}")
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"Failed to load policies from entry points: {e}")
|
| 94 |
+
|
| 95 |
+
def load_policies_from_yaml(self, yaml_path: Path) -> None:
|
| 96 |
+
"""Load policies from a YAML specification file.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
yaml_path: Path to the YAML file
|
| 100 |
+
"""
|
| 101 |
+
try:
|
| 102 |
+
with open(yaml_path, 'r') as f:
|
| 103 |
+
config = yaml.safe_load(f)
|
| 104 |
+
|
| 105 |
+
policies_config = config.get('policies', [])
|
| 106 |
+
for policy_config in policies_config:
|
| 107 |
+
try:
|
| 108 |
+
policy_name = policy_config['name']
|
| 109 |
+
policy_type = policy_config['type']
|
| 110 |
+
policy_params = policy_config.get('parameters', {})
|
| 111 |
+
|
| 112 |
+
# Get the policy class
|
| 113 |
+
policy_class = self._policy_classes.get(policy_type)
|
| 114 |
+
if not policy_class:
|
| 115 |
+
logger.error(f"Unknown policy type: {policy_type}")
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
# Create and register the policy
|
| 119 |
+
policy = policy_class(name=policy_name, **policy_params)
|
| 120 |
+
self.register_policy(policy)
|
| 121 |
+
|
| 122 |
+
except KeyError as e:
|
| 123 |
+
logger.error(f"Missing required field in policy config: {e}")
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.error(f"Failed to load policy from config: {e}")
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"Failed to load policies from YAML file {yaml_path}: {e}")
|
| 129 |
+
|
| 130 |
+
def register_policy_class(self, name: str, policy_class: Type[Policy]) -> None:
|
| 131 |
+
"""Register a policy class for dynamic instantiation.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
name: The name to register the policy class under
|
| 135 |
+
policy_class: The policy class to register
|
| 136 |
+
"""
|
| 137 |
+
if not issubclass(policy_class, Policy):
|
| 138 |
+
raise ValueError(f"Class {policy_class} must inherit from Policy")
|
| 139 |
+
|
| 140 |
+
self._policy_classes[name] = policy_class
|
| 141 |
+
logger.info(f"Registered policy class: {name}")
|
| 142 |
+
|
| 143 |
+
def create_policy_from_config(self, config: Dict[str, Any]) -> Optional[Policy]:
|
| 144 |
+
"""Create a policy instance from configuration.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
config: Configuration dictionary
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
The created policy instance, or None if creation failed
|
| 151 |
+
"""
|
| 152 |
+
try:
|
| 153 |
+
policy_type = config.get('type')
|
| 154 |
+
if not policy_type:
|
| 155 |
+
logger.error("Policy config missing 'type' field")
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
policy_class = self._policy_classes.get(policy_type)
|
| 159 |
+
if not policy_class:
|
| 160 |
+
logger.error(f"Unknown policy type: {policy_type}")
|
| 161 |
+
return None
|
| 162 |
+
|
| 163 |
+
policy_params = config.get('parameters', {})
|
| 164 |
+
policy = policy_class(**policy_params)
|
| 165 |
+
return policy
|
| 166 |
+
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.error(f"Failed to create policy from config: {e}")
|
| 169 |
+
return None
|
| 170 |
+
|
| 171 |
+
def hot_reload_policies(self, yaml_path: Path) -> None:
|
| 172 |
+
"""Hot reload policies from YAML file.
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
yaml_path: Path to the YAML file
|
| 176 |
+
"""
|
| 177 |
+
logger.info(f"Hot reloading policies from {yaml_path}")
|
| 178 |
+
|
| 179 |
+
# Clear existing policies
|
| 180 |
+
self._policies.clear()
|
| 181 |
+
|
| 182 |
+
# Reload from entry points
|
| 183 |
+
self.load_policy_from_entry_point("fastmcp.policies")
|
| 184 |
+
|
| 185 |
+
# Reload from YAML
|
| 186 |
+
if yaml_path.exists():
|
| 187 |
+
self.load_policies_from_yaml(yaml_path)
|
| 188 |
+
|
| 189 |
+
logger.info(f"Hot reload complete. {len(self._policies)} policies loaded")
|
src/fastmcp/server/policy_routes.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Policy evaluation HTTP routes."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict
|
| 4 |
+
|
| 5 |
+
from starlette.requests import Request
|
| 6 |
+
from starlette.responses import JSONResponse
|
| 7 |
+
from starlette.routing import Route
|
| 8 |
+
|
| 9 |
+
from fastmcp.policy import PolicyEngine
|
| 10 |
+
from fastmcp.utilities.logging import get_logger
|
| 11 |
+
|
| 12 |
+
logger = get_logger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def policy_evaluate_endpoint(request: Request) -> JSONResponse:
|
| 16 |
+
"""HTTP endpoint for policy evaluation.
|
| 17 |
+
|
| 18 |
+
Expected JSON body:
|
| 19 |
+
{
|
| 20 |
+
"context": {
|
| 21 |
+
"user": {...},
|
| 22 |
+
"action": "read",
|
| 23 |
+
"resource": {...},
|
| 24 |
+
...
|
| 25 |
+
},
|
| 26 |
+
"policy_names": ["policy1", "policy2"] // optional
|
| 27 |
+
}
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
# Parse request body
|
| 31 |
+
body = await request.json()
|
| 32 |
+
|
| 33 |
+
# Get policy engine from request state
|
| 34 |
+
policy_engine: PolicyEngine = request.app.state.policy_engine
|
| 35 |
+
|
| 36 |
+
# Extract context and optional policy names
|
| 37 |
+
context = body.get("context", {})
|
| 38 |
+
policy_names = body.get("policy_names")
|
| 39 |
+
|
| 40 |
+
if not context:
|
| 41 |
+
return JSONResponse(
|
| 42 |
+
status_code=400,
|
| 43 |
+
content={
|
| 44 |
+
"error": "Missing required 'context' field",
|
| 45 |
+
"reason": "Policy evaluation requires a context object"
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Evaluate policies
|
| 50 |
+
decision = await policy_engine.evaluate(context, policy_names)
|
| 51 |
+
|
| 52 |
+
# Return structured decision
|
| 53 |
+
return JSONResponse(
|
| 54 |
+
status_code=200,
|
| 55 |
+
content=decision.to_dict()
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"Policy evaluation error: {e}")
|
| 60 |
+
return JSONResponse(
|
| 61 |
+
status_code=500,
|
| 62 |
+
content={
|
| 63 |
+
"error": "Policy evaluation failed",
|
| 64 |
+
"reason": str(e)
|
| 65 |
+
}
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def create_policy_evaluate_route(policy_engine: PolicyEngine) -> Route:
|
| 70 |
+
"""Create the policy evaluation route.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
policy_engine: The policy engine instance
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
Starlette Route for policy evaluation
|
| 77 |
+
"""
|
| 78 |
+
async def endpoint_with_engine(request: Request) -> JSONResponse:
|
| 79 |
+
# Store policy engine in app state for access in endpoint
|
| 80 |
+
request.app.state.policy_engine = policy_engine
|
| 81 |
+
return await policy_evaluate_endpoint(request)
|
| 82 |
+
|
| 83 |
+
return Route(
|
| 84 |
+
path="/policy/evaluate",
|
| 85 |
+
endpoint=endpoint_with_engine,
|
| 86 |
+
methods=["POST"]
|
| 87 |
+
)
|
src/fastmcp/server/server.py
CHANGED
|
@@ -14,7 +14,7 @@ from contextlib import (
|
|
| 14 |
from dataclasses import dataclass
|
| 15 |
from functools import partial
|
| 16 |
from pathlib import Path
|
| 17 |
-
from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload
|
| 18 |
|
| 19 |
import anyio
|
| 20 |
import httpx
|
|
@@ -62,6 +62,7 @@ from fastmcp.settings import Settings
|
|
| 62 |
from fastmcp.tools import ToolManager
|
| 63 |
from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
|
| 64 |
from fastmcp.tools.tool_transform import ToolTransformConfig
|
|
|
|
| 65 |
from fastmcp.utilities.cli import log_server_banner
|
| 66 |
from fastmcp.utilities.components import FastMCPComponent
|
| 67 |
from fastmcp.utilities.logging import get_logger
|
|
@@ -173,6 +174,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 173 |
|
| 174 |
self._additional_http_routes: list[BaseRoute] = []
|
| 175 |
self._mounted_servers: list[MountedServer] = []
|
|
|
|
| 176 |
self._tool_manager = ToolManager(
|
| 177 |
duplicate_behavior=on_duplicate_tools,
|
| 178 |
mask_error_details=mask_error_details,
|
|
@@ -487,12 +489,43 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 487 |
"""
|
| 488 |
routes = list(self._additional_http_routes)
|
| 489 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
# Recursively get routes from mounted servers
|
| 491 |
for mounted_server in self._mounted_servers:
|
| 492 |
mounted_routes = mounted_server.server._get_additional_http_routes()
|
| 493 |
routes.extend(mounted_routes)
|
| 494 |
|
| 495 |
return routes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
|
| 497 |
async def _mcp_list_tools(self) -> list[MCPTool]:
|
| 498 |
logger.debug("Handler called: list_tools")
|
|
|
|
| 14 |
from dataclasses import dataclass
|
| 15 |
from functools import partial
|
| 16 |
from pathlib import Path
|
| 17 |
+
from typing import TYPE_CHECKING, Any, Generic, Literal, Optional, cast, overload
|
| 18 |
|
| 19 |
import anyio
|
| 20 |
import httpx
|
|
|
|
| 62 |
from fastmcp.tools import ToolManager
|
| 63 |
from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
|
| 64 |
from fastmcp.tools.tool_transform import ToolTransformConfig
|
| 65 |
+
from fastmcp.policy import PolicyEngine
|
| 66 |
from fastmcp.utilities.cli import log_server_banner
|
| 67 |
from fastmcp.utilities.components import FastMCPComponent
|
| 68 |
from fastmcp.utilities.logging import get_logger
|
|
|
|
| 174 |
|
| 175 |
self._additional_http_routes: list[BaseRoute] = []
|
| 176 |
self._mounted_servers: list[MountedServer] = []
|
| 177 |
+
self._policy_engine: Optional[PolicyEngine] = None
|
| 178 |
self._tool_manager = ToolManager(
|
| 179 |
duplicate_behavior=on_duplicate_tools,
|
| 180 |
mask_error_details=mask_error_details,
|
|
|
|
| 489 |
"""
|
| 490 |
routes = list(self._additional_http_routes)
|
| 491 |
|
| 492 |
+
# Add policy evaluation endpoint if policy engine is configured
|
| 493 |
+
if self._policy_engine is not None:
|
| 494 |
+
from fastmcp.server.policy_routes import create_policy_evaluate_route
|
| 495 |
+
|
| 496 |
+
policy_route = create_policy_evaluate_route(self._policy_engine)
|
| 497 |
+
routes.append(policy_route)
|
| 498 |
+
|
| 499 |
# Recursively get routes from mounted servers
|
| 500 |
for mounted_server in self._mounted_servers:
|
| 501 |
mounted_routes = mounted_server.server._get_additional_http_routes()
|
| 502 |
routes.extend(mounted_routes)
|
| 503 |
|
| 504 |
return routes
|
| 505 |
+
|
| 506 |
+
def enable_policy_engine(self, policy_engine: Optional[PolicyEngine] = None) -> PolicyEngine:
|
| 507 |
+
"""Enable the policy engine for this server.
|
| 508 |
+
|
| 509 |
+
Args:
|
| 510 |
+
policy_engine: Optional policy engine instance. If None, creates a new one.
|
| 511 |
+
|
| 512 |
+
Returns:
|
| 513 |
+
The policy engine instance
|
| 514 |
+
"""
|
| 515 |
+
if policy_engine is None:
|
| 516 |
+
policy_engine = PolicyEngine()
|
| 517 |
+
|
| 518 |
+
self._policy_engine = policy_engine
|
| 519 |
+
logger.info("Policy engine enabled for server")
|
| 520 |
+
return policy_engine
|
| 521 |
+
|
| 522 |
+
def get_policy_engine(self) -> Optional[PolicyEngine]:
|
| 523 |
+
"""Get the policy engine instance.
|
| 524 |
+
|
| 525 |
+
Returns:
|
| 526 |
+
The policy engine instance, or None if not enabled
|
| 527 |
+
"""
|
| 528 |
+
return self._policy_engine
|
| 529 |
|
| 530 |
async def _mcp_list_tools(self) -> list[MCPTool]:
|
| 531 |
logger.debug("Handler called: list_tools")
|
tests/policy/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Policy engine tests."""
|
tests/policy/test_policy_engine.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the policy engine."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from unittest.mock import Mock, patch
|
| 6 |
+
|
| 7 |
+
from fastmcp.policy import PolicyEngine, PolicyRegistry, Decision
|
| 8 |
+
from fastmcp.policy.policies import MinimumNecessaryAccessPolicy, RBACPolicy
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TestPolicyEngine:
|
| 12 |
+
"""Test the policy engine functionality."""
|
| 13 |
+
|
| 14 |
+
@pytest.fixture
|
| 15 |
+
def policy_engine(self):
|
| 16 |
+
"""Create a policy engine for testing."""
|
| 17 |
+
return PolicyEngine()
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def sample_context(self):
|
| 21 |
+
"""Create a sample context for testing."""
|
| 22 |
+
return {
|
| 23 |
+
"user": {
|
| 24 |
+
"id": "user123",
|
| 25 |
+
"roles": ["user"],
|
| 26 |
+
"permissions": ["read", "write"]
|
| 27 |
+
},
|
| 28 |
+
"action": "read",
|
| 29 |
+
"resource": {
|
| 30 |
+
"type": "document",
|
| 31 |
+
"id": "doc123",
|
| 32 |
+
"owner": "user123",
|
| 33 |
+
"visibility": "private"
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
def test_policy_engine_initialization(self, policy_engine):
|
| 38 |
+
"""Test policy engine initialization."""
|
| 39 |
+
assert policy_engine.registry is not None
|
| 40 |
+
assert isinstance(policy_engine.registry, PolicyRegistry)
|
| 41 |
+
assert policy_engine._evaluation_order == []
|
| 42 |
+
|
| 43 |
+
def test_set_evaluation_order(self, policy_engine):
|
| 44 |
+
"""Test setting evaluation order."""
|
| 45 |
+
order = ["policy1", "policy2", "policy3"]
|
| 46 |
+
policy_engine.set_evaluation_order(order)
|
| 47 |
+
assert policy_engine._evaluation_order == order
|
| 48 |
+
|
| 49 |
+
@pytest.mark.asyncio
|
| 50 |
+
async def test_evaluate_no_policies(self, policy_engine, sample_context):
|
| 51 |
+
"""Test evaluation when no policies are registered."""
|
| 52 |
+
decision = await policy_engine.evaluate(sample_context)
|
| 53 |
+
assert decision.allow is True
|
| 54 |
+
assert "All policies evaluated successfully" in decision.reason
|
| 55 |
+
|
| 56 |
+
@pytest.mark.asyncio
|
| 57 |
+
async def test_evaluate_single_policy(self, policy_engine, sample_context):
|
| 58 |
+
"""Test evaluation of a single policy."""
|
| 59 |
+
# Register a policy
|
| 60 |
+
policy = MinimumNecessaryAccessPolicy()
|
| 61 |
+
policy_engine.register_policy(policy)
|
| 62 |
+
|
| 63 |
+
# Evaluate
|
| 64 |
+
decision = await policy_engine.evaluate(sample_context)
|
| 65 |
+
assert decision.allow is True
|
| 66 |
+
# The policy engine returns its own response, not the individual policy response
|
| 67 |
+
assert "All policies evaluated successfully" in decision.reason
|
| 68 |
+
|
| 69 |
+
@pytest.mark.asyncio
|
| 70 |
+
async def test_evaluate_specific_policies(self, policy_engine, sample_context):
|
| 71 |
+
"""Test evaluation of specific policies."""
|
| 72 |
+
# Register multiple policies
|
| 73 |
+
policy1 = MinimumNecessaryAccessPolicy(name="policy1")
|
| 74 |
+
policy2 = RBACPolicy(name="policy2")
|
| 75 |
+
policy_engine.register_policy(policy1)
|
| 76 |
+
policy_engine.register_policy(policy2)
|
| 77 |
+
|
| 78 |
+
# Evaluate only policy1
|
| 79 |
+
decision = await policy_engine.evaluate(sample_context, ["policy1"])
|
| 80 |
+
assert decision.allow is True
|
| 81 |
+
|
| 82 |
+
@pytest.mark.asyncio
|
| 83 |
+
async def test_evaluate_policy_denial(self, policy_engine):
|
| 84 |
+
"""Test evaluation when a policy denies access."""
|
| 85 |
+
# Create context with sensitive action
|
| 86 |
+
context = {
|
| 87 |
+
"user": {"roles": ["user"]},
|
| 88 |
+
"action": "delete",
|
| 89 |
+
"resource": {"type": "user_data"}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# Register minimum necessary policy
|
| 93 |
+
policy = MinimumNecessaryAccessPolicy()
|
| 94 |
+
policy_engine.register_policy(policy)
|
| 95 |
+
|
| 96 |
+
# Evaluate
|
| 97 |
+
decision = await policy_engine.evaluate(context)
|
| 98 |
+
assert decision.allow is False
|
| 99 |
+
assert "requires justification" in decision.reason
|
| 100 |
+
|
| 101 |
+
@pytest.mark.asyncio
|
| 102 |
+
async def test_evaluate_single_policy_method(self, policy_engine, sample_context):
|
| 103 |
+
"""Test evaluate_single_policy method."""
|
| 104 |
+
# Register a policy
|
| 105 |
+
policy = MinimumNecessaryAccessPolicy(name="test_policy")
|
| 106 |
+
policy_engine.register_policy(policy)
|
| 107 |
+
|
| 108 |
+
# Evaluate single policy
|
| 109 |
+
decision = await policy_engine.evaluate_single_policy("test_policy", sample_context)
|
| 110 |
+
assert decision is not None
|
| 111 |
+
assert decision.allow is True
|
| 112 |
+
|
| 113 |
+
@pytest.mark.asyncio
|
| 114 |
+
async def test_evaluate_single_policy_not_found(self, policy_engine, sample_context):
|
| 115 |
+
"""Test evaluate_single_policy with non-existent policy."""
|
| 116 |
+
decision = await policy_engine.evaluate_single_policy("non_existent", sample_context)
|
| 117 |
+
assert decision is None
|
| 118 |
+
|
| 119 |
+
def test_get_policy_metadata(self, policy_engine):
|
| 120 |
+
"""Test getting policy metadata."""
|
| 121 |
+
# Register policies
|
| 122 |
+
policy1 = MinimumNecessaryAccessPolicy(name="policy1")
|
| 123 |
+
policy2 = RBACPolicy(name="policy2")
|
| 124 |
+
policy_engine.register_policy(policy1)
|
| 125 |
+
policy_engine.register_policy(policy2)
|
| 126 |
+
|
| 127 |
+
metadata = policy_engine.get_policy_metadata()
|
| 128 |
+
assert len(metadata) == 2
|
| 129 |
+
assert any(p["name"] == "policy1" for p in metadata)
|
| 130 |
+
assert any(p["name"] == "policy2" for p in metadata)
|
| 131 |
+
|
| 132 |
+
def test_register_unregister_policy(self, policy_engine):
|
| 133 |
+
"""Test policy registration and unregistration."""
|
| 134 |
+
policy = MinimumNecessaryAccessPolicy(name="test_policy")
|
| 135 |
+
|
| 136 |
+
# Register
|
| 137 |
+
policy_engine.register_policy(policy)
|
| 138 |
+
assert policy_engine.registry.get_policy("test_policy") is not None
|
| 139 |
+
|
| 140 |
+
# Unregister
|
| 141 |
+
unregistered = policy_engine.unregister_policy("test_policy")
|
| 142 |
+
assert unregistered is not None
|
| 143 |
+
assert unregistered.name == "test_policy"
|
| 144 |
+
assert policy_engine.registry.get_policy("test_policy") is None
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class TestPolicyRegistry:
|
| 148 |
+
"""Test the policy registry functionality."""
|
| 149 |
+
|
| 150 |
+
@pytest.fixture
|
| 151 |
+
def registry(self):
|
| 152 |
+
"""Create a policy registry for testing."""
|
| 153 |
+
return PolicyRegistry()
|
| 154 |
+
|
| 155 |
+
def test_registry_initialization(self, registry):
|
| 156 |
+
"""Test registry initialization."""
|
| 157 |
+
assert registry._policies == {}
|
| 158 |
+
assert registry._policy_classes == {}
|
| 159 |
+
|
| 160 |
+
def test_register_policy(self, registry):
|
| 161 |
+
"""Test policy registration."""
|
| 162 |
+
policy = MinimumNecessaryAccessPolicy()
|
| 163 |
+
registry.register_policy(policy)
|
| 164 |
+
assert "minimum_necessary_access" in registry._policies
|
| 165 |
+
|
| 166 |
+
def test_unregister_policy(self, registry):
|
| 167 |
+
"""Test policy unregistration."""
|
| 168 |
+
policy = MinimumNecessaryAccessPolicy()
|
| 169 |
+
registry.register_policy(policy)
|
| 170 |
+
|
| 171 |
+
unregistered = registry.unregister_policy("minimum_necessary_access")
|
| 172 |
+
assert unregistered is not None
|
| 173 |
+
assert "minimum_necessary_access" not in registry._policies
|
| 174 |
+
|
| 175 |
+
def test_get_policy(self, registry):
|
| 176 |
+
"""Test getting a policy."""
|
| 177 |
+
policy = MinimumNecessaryAccessPolicy()
|
| 178 |
+
registry.register_policy(policy)
|
| 179 |
+
|
| 180 |
+
retrieved = registry.get_policy("minimum_necessary_access")
|
| 181 |
+
assert retrieved is not None
|
| 182 |
+
assert retrieved.name == "minimum_necessary_access"
|
| 183 |
+
|
| 184 |
+
def test_list_policies(self, registry):
|
| 185 |
+
"""Test listing policies."""
|
| 186 |
+
policy1 = MinimumNecessaryAccessPolicy(name="policy1")
|
| 187 |
+
policy2 = RBACPolicy(name="policy2")
|
| 188 |
+
registry.register_policy(policy1)
|
| 189 |
+
registry.register_policy(policy2)
|
| 190 |
+
|
| 191 |
+
policies = registry.list_policies()
|
| 192 |
+
assert len(policies) == 2
|
| 193 |
+
assert any(p["name"] == "policy1" for p in policies)
|
| 194 |
+
assert any(p["name"] == "policy2" for p in policies)
|
| 195 |
+
|
| 196 |
+
def test_register_policy_class(self, registry):
|
| 197 |
+
"""Test registering a policy class."""
|
| 198 |
+
registry.register_policy_class("test_policy", MinimumNecessaryAccessPolicy)
|
| 199 |
+
assert "test_policy" in registry._policy_classes
|
| 200 |
+
assert registry._policy_classes["test_policy"] == MinimumNecessaryAccessPolicy
|
| 201 |
+
|
| 202 |
+
def test_register_invalid_policy_class(self, registry):
|
| 203 |
+
"""Test registering an invalid policy class."""
|
| 204 |
+
with pytest.raises(ValueError):
|
| 205 |
+
registry.register_policy_class("invalid", str)
|
| 206 |
+
|
| 207 |
+
def test_create_policy_from_config(self, registry):
|
| 208 |
+
"""Test creating policy from configuration."""
|
| 209 |
+
registry.register_policy_class("test_policy", MinimumNecessaryAccessPolicy)
|
| 210 |
+
|
| 211 |
+
config = {
|
| 212 |
+
"type": "test_policy",
|
| 213 |
+
"parameters": {
|
| 214 |
+
"name": "config_policy",
|
| 215 |
+
"required_justification": False
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
policy = registry.create_policy_from_config(config)
|
| 220 |
+
assert policy is not None
|
| 221 |
+
assert policy.name == "config_policy"
|
| 222 |
+
assert isinstance(policy, MinimumNecessaryAccessPolicy)
|
| 223 |
+
|
| 224 |
+
def test_create_policy_from_invalid_config(self, registry):
|
| 225 |
+
"""Test creating policy from invalid configuration."""
|
| 226 |
+
config = {"type": "non_existent"}
|
| 227 |
+
policy = registry.create_policy_from_config(config)
|
| 228 |
+
assert policy is None
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class TestPolicyLoadAndReload:
|
| 232 |
+
"""Test policy loading and hot-reload functionality."""
|
| 233 |
+
|
| 234 |
+
@pytest.fixture
|
| 235 |
+
def registry(self):
|
| 236 |
+
"""Create a policy registry for testing."""
|
| 237 |
+
return PolicyRegistry()
|
| 238 |
+
|
| 239 |
+
@pytest.fixture
|
| 240 |
+
def yaml_config_file(self, tmp_path):
|
| 241 |
+
"""Create a temporary YAML config file."""
|
| 242 |
+
config_content = """
|
| 243 |
+
policies:
|
| 244 |
+
- name: yaml_policy1
|
| 245 |
+
type: minimum_necessary
|
| 246 |
+
parameters:
|
| 247 |
+
required_justification: false
|
| 248 |
+
- name: yaml_policy2
|
| 249 |
+
type: rbac
|
| 250 |
+
parameters:
|
| 251 |
+
version: "1.0.0"
|
| 252 |
+
"""
|
| 253 |
+
config_file = tmp_path / "policies.yaml"
|
| 254 |
+
config_file.write_text(config_content)
|
| 255 |
+
return config_file
|
| 256 |
+
|
| 257 |
+
def test_load_policies_from_yaml(self, registry, yaml_config_file):
|
| 258 |
+
"""Test loading policies from YAML file."""
|
| 259 |
+
# Register policy classes
|
| 260 |
+
registry.register_policy_class("minimum_necessary", MinimumNecessaryAccessPolicy)
|
| 261 |
+
registry.register_policy_class("rbac", RBACPolicy)
|
| 262 |
+
|
| 263 |
+
# Load from YAML
|
| 264 |
+
registry.load_policies_from_yaml(yaml_config_file)
|
| 265 |
+
|
| 266 |
+
# Check that policies were loaded
|
| 267 |
+
assert registry.get_policy("yaml_policy1") is not None
|
| 268 |
+
assert registry.get_policy("yaml_policy2") is not None
|
| 269 |
+
|
| 270 |
+
def test_hot_reload_policies(self, registry, yaml_config_file):
|
| 271 |
+
"""Test hot reloading policies."""
|
| 272 |
+
# Register policy classes
|
| 273 |
+
registry.register_policy_class("minimum_necessary", MinimumNecessaryAccessPolicy)
|
| 274 |
+
registry.register_policy_class("rbac", RBACPolicy)
|
| 275 |
+
|
| 276 |
+
# Initial load
|
| 277 |
+
registry.load_policies_from_yaml(yaml_config_file)
|
| 278 |
+
initial_count = len(registry._policies)
|
| 279 |
+
|
| 280 |
+
# Hot reload
|
| 281 |
+
registry.hot_reload_policies(yaml_config_file)
|
| 282 |
+
|
| 283 |
+
# Check that policies were reloaded
|
| 284 |
+
assert len(registry._policies) == initial_count
|
| 285 |
+
assert registry.get_policy("yaml_policy1") is not None
|
| 286 |
+
assert registry.get_policy("yaml_policy2") is not None
|
| 287 |
+
|
| 288 |
+
@patch('importlib.metadata.entry_points')
|
| 289 |
+
def test_load_policy_from_entry_point(self, mock_entry_points, registry):
|
| 290 |
+
"""Test loading policies from entry points."""
|
| 291 |
+
# Mock entry points
|
| 292 |
+
mock_entry_point = Mock()
|
| 293 |
+
mock_entry_point.name = "test_policy"
|
| 294 |
+
mock_entry_point.load.return_value = MinimumNecessaryAccessPolicy
|
| 295 |
+
|
| 296 |
+
mock_entry_points.return_value.select.return_value = [mock_entry_point]
|
| 297 |
+
|
| 298 |
+
# Load from entry points
|
| 299 |
+
registry.load_policy_from_entry_point("fastmcp.policies")
|
| 300 |
+
|
| 301 |
+
# Check that policy was loaded
|
| 302 |
+
assert registry.get_policy("test_policy") is not None
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
class TestPolicyIntegration:
|
| 306 |
+
"""Test policy integration with FastMCP server."""
|
| 307 |
+
|
| 308 |
+
@pytest.mark.asyncio
|
| 309 |
+
async def test_policy_engine_with_server(self):
|
| 310 |
+
"""Test policy engine integration with FastMCP server."""
|
| 311 |
+
from fastmcp import FastMCP
|
| 312 |
+
|
| 313 |
+
# Create server with policy engine
|
| 314 |
+
server = FastMCP("Test Server")
|
| 315 |
+
policy_engine = server.enable_policy_engine()
|
| 316 |
+
|
| 317 |
+
# Register policies
|
| 318 |
+
policy_engine.register_policy(MinimumNecessaryAccessPolicy())
|
| 319 |
+
policy_engine.register_policy(RBACPolicy())
|
| 320 |
+
|
| 321 |
+
# Test that policy engine is accessible
|
| 322 |
+
assert server.get_policy_engine() is not None
|
| 323 |
+
assert server.get_policy_engine() == policy_engine
|
| 324 |
+
|
| 325 |
+
# Test policy evaluation
|
| 326 |
+
context = {
|
| 327 |
+
"user": {"roles": ["user"]},
|
| 328 |
+
"action": "read",
|
| 329 |
+
"resource": {"type": "document"}
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
decision = await policy_engine.evaluate(context)
|
| 333 |
+
assert decision.allow is True
|
tests/policy/test_policy_http.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for policy HTTP endpoints."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from fastmcp import FastMCP
|
| 5 |
+
from fastmcp.policy import PolicyEngine
|
| 6 |
+
from fastmcp.policy.policies import MinimumNecessaryAccessPolicy, RBACPolicy
|
| 7 |
+
from starlette.routing import Route
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestPolicyHTTPEndpoint:
|
| 11 |
+
"""Test the policy evaluation HTTP endpoint."""
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def server_with_policy(self):
|
| 15 |
+
"""Create a server with policy engine enabled."""
|
| 16 |
+
server = FastMCP("Test Policy Server")
|
| 17 |
+
policy_engine = server.enable_policy_engine()
|
| 18 |
+
|
| 19 |
+
# Register policies
|
| 20 |
+
policy_engine.register_policy(MinimumNecessaryAccessPolicy())
|
| 21 |
+
policy_engine.register_policy(RBACPolicy())
|
| 22 |
+
|
| 23 |
+
return server
|
| 24 |
+
|
| 25 |
+
@pytest.fixture
|
| 26 |
+
def app(self, server_with_policy):
|
| 27 |
+
"""Create the HTTP app with policy endpoint."""
|
| 28 |
+
return server_with_policy.http_app(transport="sse")
|
| 29 |
+
|
| 30 |
+
def test_policy_evaluate_endpoint_exists(self, app):
|
| 31 |
+
"""Test that the policy evaluation endpoint exists in the app."""
|
| 32 |
+
# Check that the policy route exists
|
| 33 |
+
policy_route_found = False
|
| 34 |
+
for route in app.routes:
|
| 35 |
+
if isinstance(route, Route) and route.path == "/policy/evaluate":
|
| 36 |
+
policy_route_found = True
|
| 37 |
+
break
|
| 38 |
+
|
| 39 |
+
assert policy_route_found, "Policy evaluation endpoint not found in app routes"
|
| 40 |
+
|
| 41 |
+
def test_policy_engine_integration(self, server_with_policy):
|
| 42 |
+
"""Test that policy engine is properly integrated with the server."""
|
| 43 |
+
# Check that policy engine is enabled
|
| 44 |
+
assert server_with_policy.get_policy_engine() is not None
|
| 45 |
+
|
| 46 |
+
# Check that policies are registered
|
| 47 |
+
policy_engine = server_with_policy.get_policy_engine()
|
| 48 |
+
policies = policy_engine.get_policy_metadata()
|
| 49 |
+
assert len(policies) == 2
|
| 50 |
+
|
| 51 |
+
# Check that both policies are present
|
| 52 |
+
policy_names = [p["name"] for p in policies]
|
| 53 |
+
assert "minimum_necessary_access" in policy_names
|
| 54 |
+
assert "rbac" in policy_names
|