Compare commits
No commits in common. "main" and "feature/oauth-authentication" have entirely different histories.
main
...
feature/oa
@ -1,82 +0,0 @@
|
|||||||
# mcvsphere OAuth Configuration Template
|
|
||||||
# Copy to .env and configure for your environment
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# cp .env.oauth.example .env
|
|
||||||
# # Edit .env with your values
|
|
||||||
# docker compose -f docker-compose.oauth-standalone.yml up -d
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Docker Compose
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
COMPOSE_PROJECT_NAME=mcvsphere
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# vCenter Connection (Required)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
VCENTER_HOST=vcenter.example.com
|
|
||||||
VCENTER_USER=mcpservice@vsphere.local
|
|
||||||
VCENTER_PASSWORD=your-secure-password
|
|
||||||
|
|
||||||
# Optional: Skip SSL verification (dev only - use false in production)
|
|
||||||
VCENTER_INSECURE=false
|
|
||||||
|
|
||||||
# Optional: Specify defaults (auto-detected if not set)
|
|
||||||
# VCENTER_DATACENTER=Datacenter
|
|
||||||
# VCENTER_CLUSTER=Cluster
|
|
||||||
# VCENTER_DATASTORE=datastore1
|
|
||||||
# VCENTER_NETWORK=VM Network
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# MCP Transport (Required for OAuth)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
MCP_TRANSPORT=streamable-http
|
|
||||||
MCP_HOST=0.0.0.0
|
|
||||||
MCP_PORT=8080
|
|
||||||
|
|
||||||
# Your public domain (must match Caddy proxy)
|
|
||||||
MCP_DOMAIN=mcp.example.com
|
|
||||||
|
|
||||||
# TLS mode: 'internal' for self-signed (dev), remove for auto HTTPS (prod)
|
|
||||||
MCP_TLS_MODE=internal
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# OAuth / OIDC Provider (Required)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
OAUTH_ENABLED=true
|
|
||||||
|
|
||||||
# OIDC Discovery URL (ends with /.well-known/openid-configuration)
|
|
||||||
# Examples:
|
|
||||||
# Authentik: https://auth.example.com/application/o/mcvsphere/
|
|
||||||
# Keycloak: https://keycloak.example.com/realms/myrealm
|
|
||||||
# Auth0: https://myapp.auth0.com/
|
|
||||||
# Okta: https://myorg.okta.com/oauth2/default
|
|
||||||
OAUTH_ISSUER_URL=https://auth.example.com/application/o/mcvsphere/
|
|
||||||
|
|
||||||
# OAuth Client Credentials (from your OIDC provider)
|
|
||||||
OAUTH_CLIENT_ID=your-client-id
|
|
||||||
OAUTH_CLIENT_SECRET=your-client-secret
|
|
||||||
|
|
||||||
# Public callback URL (must be accessible from browser)
|
|
||||||
OAUTH_BASE_URL=https://mcp.example.com
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# RBAC Permission Groups
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Create these groups in your OIDC provider and assign users:
|
|
||||||
#
|
|
||||||
# | Group | Access Level |
|
|
||||||
# |------------------------|---------------------------------|
|
|
||||||
# | vsphere-super-admins | Full control (all 94 tools) |
|
|
||||||
# | vsphere-host-admins | Host operations + VM management |
|
|
||||||
# | vsphere-admins | VM lifecycle management |
|
|
||||||
# | vsphere-operators | Power ops + snapshots |
|
|
||||||
# | vsphere-readers | Read-only |
|
|
||||||
#
|
|
||||||
# Users without any vsphere-* group will be denied access (default-deny).
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Optional Settings
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# LOG_LEVEL=INFO
|
|
||||||
# MCVSPHERE_VERSION=0.2.2
|
|
||||||
14
Dockerfile
14
Dockerfile
@ -48,11 +48,8 @@ ENV PATH="/app/.venv/bin:$PATH"
|
|||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
# Default transport - override with MCP_TRANSPORT env var
|
# Default to SSE transport for Docker
|
||||||
# stdio: Direct CLI usage (default for local)
|
ENV MCP_TRANSPORT=sse
|
||||||
# sse: Server-Sent Events (legacy HTTP)
|
|
||||||
# streamable-http: HTTP with OAuth support (required for multi-user)
|
|
||||||
ENV MCP_TRANSPORT=streamable-http
|
|
||||||
ENV MCP_HOST=0.0.0.0
|
ENV MCP_HOST=0.0.0.0
|
||||||
ENV MCP_PORT=8080
|
ENV MCP_PORT=8080
|
||||||
|
|
||||||
@ -61,9 +58,10 @@ USER mcpuser
|
|||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Health check - works with both SSE and streamable-http
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/.well-known/oauth-authorization-server')" || exit 1
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080')" || exit 1
|
||||||
|
|
||||||
# Run the MCP server - transport configured via environment
|
# Run the MCP server
|
||||||
ENTRYPOINT ["mcvsphere"]
|
ENTRYPOINT ["mcvsphere"]
|
||||||
|
CMD ["--transport", "sse"]
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
# OAuth & RBAC Architecture for mcvsphere
|
# OAuth Architecture for vSphere MCP Server
|
||||||
|
|
||||||
## Overview
|
## The Problem
|
||||||
|
|
||||||
mcvsphere supports multi-user OAuth 2.1 authentication with Role-Based Access Control (RBAC). This enables:
|
We need to add authentication to the MCP server so that:
|
||||||
|
1. Users authenticate via **OAuth 2.1 / OIDC** (using Authentik as IdP)
|
||||||
|
2. The MCP server knows WHO is making requests (for audit logging)
|
||||||
|
3. vCenter permissions are respected per-user
|
||||||
|
|
||||||
1. **Single Sign-On** via any OIDC provider (Authentik, Keycloak, Auth0, etc.)
|
**Challenge:** vCenter 7.0.3 doesn't support OAuth token exchange (RFC 8693), so we can't pass OAuth tokens directly to vCenter.
|
||||||
2. **User Identity** for audit logging - know WHO made each request
|
|
||||||
3. **Group-Based Permissions** - control what users can do based on OAuth groups
|
|
||||||
4. **Audit Trail** - every tool invocation logged with user identity and timing
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
@ -22,352 +22,360 @@ mcvsphere supports multi-user OAuth 2.1 authentication with Role-Based Access Co
|
|||||||
│ (browser opens for login)
|
│ (browser opens for login)
|
||||||
│
|
│
|
||||||
┌────────────────────────────▼────────────────────────────────────┐
|
┌────────────────────────────▼────────────────────────────────────┐
|
||||||
│ OIDC Provider │
|
│ Authentik │
|
||||||
│ (Authentik, Keycloak, Auth0, etc.) │
|
│ (Self-hosted OIDC IdP) │
|
||||||
│ │
|
│ │
|
||||||
│ - Issues JWT access tokens │
|
│ - Issues JWT access tokens │
|
||||||
│ - Validates user credentials │
|
│ - Validates user credentials │
|
||||||
│ - Includes groups claim in token │
|
│ - Includes user identity in token (sub, email, groups) │
|
||||||
└────────────────────────────┬────────────────────────────────────┘
|
└────────────────────────────┬────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
│ 2. JWT Bearer token
|
│ 2. JWT Bearer token
|
||||||
│ Authorization: Bearer <jwt>
|
│ Authorization: Bearer <jwt>
|
||||||
│
|
│
|
||||||
┌────────────────────────────▼────────────────────────────────────┐
|
┌────────────────────────────▼────────────────────────────────────┐
|
||||||
│ mcvsphere │
|
│ vSphere MCP Server │
|
||||||
│ (FastMCP + pyvmomi) │
|
│ (FastMCP + pyvmomi) │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
│ │ OIDCProxy (FastMCP) │ │
|
│ │ OIDCProxy (FastMCP) │ │
|
||||||
│ │ - Validates JWT signature via JWKS endpoint │ │
|
│ │ - Validates JWT signature via Authentik JWKS │ │
|
||||||
│ │ - Extracts user identity (preferred_username, email) │ │
|
│ │ - Extracts user identity (preferred_username) │ │
|
||||||
│ │ - Extracts groups from token claims │ │
|
│ │ - Makes user available via ctx.request_context.user │ │
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
│ │ RBACMiddleware │ │
|
│ │ Credential Broker │ │
|
||||||
│ │ - Intercepts ALL tool calls via on_call_tool() │ │
|
│ │ - Maps OAuth user → vCenter credentials │ │
|
||||||
│ │ - Maps OAuth groups → Permission levels │ │
|
│ │ - Caches pyvmomi connections per-user │ │
|
||||||
│ │ - Denies access if user lacks required permission │ │
|
│ │ - Retrieves passwords from Vault / env vars │ │
|
||||||
│ │ - Logs audit events with user identity │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
│ │ VMware Tools (94) │ │
|
│ │ Audit Logger │ │
|
||||||
│ │ - Execute vCenter/ESXi operations via pyvmomi │ │
|
│ │ - Logs all tool invocations with OAuth identity │ │
|
||||||
│ │ - Single service account connection to vCenter │ │
|
│ │ - "User ryan@example.com powered on VM web-server" │ │
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
└────────────────────────────┬────────────────────────────────────┘
|
└────────────────────────────┬────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
│ 3. pyvmomi (service account)
|
│ 3. pyvmomi (as mapped user)
|
||||||
│
|
│
|
||||||
┌────────────────────────────▼────────────────────────────────────┐
|
┌────────────────────────────▼────────────────────────────────────┐
|
||||||
│ vCenter / ESXi │
|
│ vCenter 7.0.3 │
|
||||||
│ - Receives API calls as service account │
|
│ - Receives API calls as the actual user │
|
||||||
│ - mcvsphere audit logs show real user identity │
|
│ - Native audit logs show real user identity │
|
||||||
|
│ - vCenter permissions apply naturally │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## RBAC Permission Model
|
## User Mapping Strategies
|
||||||
|
|
||||||
### Permission Levels
|
Since we can't exchange OAuth tokens for vCenter tokens, we need a "credential broker":
|
||||||
|
|
||||||
mcvsphere defines 5 permission levels, from least to most privileged:
|
| Strategy | How it Works | Security | Use Case |
|
||||||
|
|----------|--------------|----------|----------|
|
||||||
|
| **Service Account** | All requests use one vCenter admin account | Medium | Simple/dev |
|
||||||
|
| **Per-User Mapping** | Map OAuth username → vCenter credentials from Vault | High | Production |
|
||||||
|
| **LDAP Sync** | Same username/password in Authentik and vCenter SSO | Medium | AD environments |
|
||||||
|
|
||||||
| Level | Description | Example Tools |
|
### Recommended: Per-User Mapping with Fallback
|
||||||
|-------|-------------|---------------|
|
|
||||||
| `READ_ONLY` | View-only operations | `list_vms`, `get_vm_info`, `vm_screenshot` |
|
|
||||||
| `POWER_OPS` | Power and snapshot operations | `power_on`, `create_snapshot`, `reboot_guest` |
|
|
||||||
| `VM_LIFECYCLE` | Create/delete/modify VMs | `create_vm`, `clone_vm`, `add_disk`, `deploy_ovf` |
|
|
||||||
| `HOST_ADMIN` | ESXi host operations | `reboot_host`, `enter_maintenance_mode` |
|
|
||||||
| `FULL_ADMIN` | Everything including guest OS ops | `run_command_in_guest`, `restart_service` |
|
|
||||||
|
|
||||||
### OAuth Groups → Permissions
|
|
||||||
|
|
||||||
Users are granted permissions based on their OAuth group memberships:
|
|
||||||
|
|
||||||
| OAuth Group | Permissions Granted |
|
|
||||||
|-------------|---------------------|
|
|
||||||
| `vsphere-readers` | READ_ONLY |
|
|
||||||
| `vsphere-operators` | READ_ONLY, POWER_OPS |
|
|
||||||
| `vsphere-admins` | READ_ONLY, POWER_OPS, VM_LIFECYCLE |
|
|
||||||
| `vsphere-host-admins` | READ_ONLY, POWER_OPS, VM_LIFECYCLE, HOST_ADMIN |
|
|
||||||
| `vsphere-super-admins` | ALL (full access) |
|
|
||||||
|
|
||||||
**Security Note:** Users with NO recognized groups are denied ALL access. There is no default permission.
|
|
||||||
|
|
||||||
### Tool → Permission Mapping
|
|
||||||
|
|
||||||
All 94 tools are mapped to permission levels in `src/mcvsphere/permissions.py`:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# READ_ONLY - 32 tools
|
class CredentialBroker:
|
||||||
"list_vms", "get_vm_info", "list_snapshots", "get_vm_stats", ...
|
"""Maps OAuth users to vCenter credentials."""
|
||||||
|
|
||||||
# POWER_OPS - 14 tools
|
def __init__(self, vcenter_host: str, fallback_user: str = None, fallback_password: str = None):
|
||||||
"power_on", "power_off", "create_snapshot", "revert_to_snapshot", ...
|
self.vcenter_host = vcenter_host
|
||||||
|
self.fallback_user = fallback_user # Service account fallback
|
||||||
|
self.fallback_password = fallback_password
|
||||||
|
self._connections: dict[str, ServiceInstance] = {}
|
||||||
|
|
||||||
# VM_LIFECYCLE - 33 tools
|
def get_connection_for_user(self, oauth_user: dict) -> ServiceInstance:
|
||||||
"create_vm", "clone_vm", "delete_vm", "add_disk", "deploy_ovf", ...
|
"""Get pyvmomi connection for this OAuth user."""
|
||||||
|
username = oauth_user.get("preferred_username")
|
||||||
|
|
||||||
# HOST_ADMIN - 6 tools
|
# Try per-user credentials first
|
||||||
"enter_maintenance_mode", "reboot_host", "shutdown_host", ...
|
try:
|
||||||
|
vcenter_creds = self._lookup_credentials(username)
|
||||||
|
return self._get_or_create_connection(
|
||||||
|
vcenter_creds["user"],
|
||||||
|
vcenter_creds["password"]
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
# Fall back to service account
|
||||||
|
if self.fallback_user:
|
||||||
|
return self._get_or_create_connection(
|
||||||
|
self.fallback_user,
|
||||||
|
self.fallback_password
|
||||||
|
)
|
||||||
|
raise ValueError(f"No vCenter credentials for user: {username}")
|
||||||
|
|
||||||
# FULL_ADMIN - 11 tools
|
def _lookup_credentials(self, username: str) -> dict:
|
||||||
"run_command_in_guest", "write_guest_file", "restart_service", ...
|
"""Look up vCenter credentials for OAuth user."""
|
||||||
|
# Option 1: Environment variable
|
||||||
|
env_key = f"VCENTER_PASSWORD_{username.upper().replace('@', '_').replace('.', '_')}"
|
||||||
|
if password := os.environ.get(env_key):
|
||||||
|
return {"user": f"{username}@vsphere.local", "password": password}
|
||||||
|
|
||||||
|
# Option 2: HashiCorp Vault (production)
|
||||||
|
# return vault_client.read(f"secret/vcenter/users/{username}")
|
||||||
|
|
||||||
|
raise KeyError(f"No credentials found for {username}")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Details
|
## FastMCP OAuth Integration
|
||||||
|
|
||||||
### Key Files
|
### 1. Add OIDCProxy to server.py
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `src/mcvsphere/auth.py` | OIDCProxy configuration |
|
|
||||||
| `src/mcvsphere/permissions.py` | Permission levels and tool mappings |
|
|
||||||
| `src/mcvsphere/middleware.py` | RBACMiddleware implementation |
|
|
||||||
| `src/mcvsphere/audit.py` | Audit logging with user context |
|
|
||||||
| `src/mcvsphere/server.py` | Server setup with OAuth + RBAC |
|
|
||||||
|
|
||||||
### RBACMiddleware Flow
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class RBACMiddleware(Middleware):
|
import os
|
||||||
"""Intercepts all tool calls to enforce permissions."""
|
from fastmcp import FastMCP
|
||||||
|
from fastmcp.server.auth import OIDCProxy
|
||||||
|
|
||||||
async def on_call_tool(self, context, call_next):
|
# Configure OAuth with Authentik
|
||||||
# 1. Extract user from OAuth token
|
auth = OIDCProxy(
|
||||||
claims = self._extract_user_from_context(context.fastmcp_context)
|
# Authentik OIDC Discovery URL
|
||||||
username = claims.get("preferred_username", "unknown")
|
config_url=os.environ["AUTHENTIK_OIDC_URL"],
|
||||||
groups = claims.get("groups", [])
|
# e.g., "https://auth.example.com/application/o/vsphere-mcp/.well-known/openid-configuration"
|
||||||
|
|
||||||
# 2. Check permission
|
# Application credentials from Authentik
|
||||||
tool_name = context.message.name
|
client_id=os.environ["AUTHENTIK_CLIENT_ID"],
|
||||||
if not check_permission(tool_name, groups):
|
client_secret=os.environ["AUTHENTIK_CLIENT_SECRET"],
|
||||||
required = get_required_permission(tool_name)
|
|
||||||
audit_permission_denied(tool_name, {...}, required.value)
|
|
||||||
raise PermissionDeniedError(username, tool_name, required)
|
|
||||||
|
|
||||||
# 3. Execute tool with timing
|
# MCP Server base URL (for redirects)
|
||||||
start = time.perf_counter()
|
base_url=os.environ.get("MCP_BASE_URL", "http://localhost:8000"),
|
||||||
result = await call_next(context)
|
|
||||||
duration_ms = (time.perf_counter() - start) * 1000
|
|
||||||
|
|
||||||
# 4. Audit log
|
# Token validation
|
||||||
audit_log(tool_name, {...}, result="success", duration_ms=duration_ms)
|
required_scopes=["openid", "profile", "email"],
|
||||||
return result
|
|
||||||
|
# Allow Claude Code localhost redirects
|
||||||
|
allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create MCP server with OAuth
|
||||||
|
mcp = FastMCP(
|
||||||
|
"vSphere MCP Server",
|
||||||
|
auth=auth,
|
||||||
|
# Use Streamable HTTP transport for OAuth
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Audit Log Format
|
### 2. Access User Identity in Tools
|
||||||
|
|
||||||
```json
|
```python
|
||||||
{
|
from fastmcp import Context
|
||||||
"timestamp": "2025-12-27T08:15:32.123456+00:00",
|
|
||||||
"user": "ryan@example.com",
|
|
||||||
"groups": ["vsphere-admins", "vsphere-operators"],
|
|
||||||
"tool": "power_on",
|
|
||||||
"args": {"vm_name": "web-server"},
|
|
||||||
"duration_ms": 1234.56,
|
|
||||||
"result": "success"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Permission denied events:
|
@mcp.tool()
|
||||||
```json
|
async def power_on_vm(ctx: Context, vm_name: str) -> str:
|
||||||
{
|
"""Power on a virtual machine."""
|
||||||
"timestamp": "2025-12-27T08:15:32.123456+00:00",
|
# Get authenticated user from OAuth token
|
||||||
"user": "guest@example.com",
|
user = ctx.request_context.user
|
||||||
"groups": ["vsphere-readers"],
|
username = user.get("preferred_username", user.get("sub"))
|
||||||
"tool": "delete_vm",
|
|
||||||
"args": {"vm_name": "web-server"},
|
# Get vCenter connection for this user
|
||||||
"required_permission": "vm_lifecycle",
|
broker = get_credential_broker()
|
||||||
"event": "PERMISSION_DENIED"
|
connection = broker.get_connection_for_user(user)
|
||||||
}
|
|
||||||
|
# Execute operation
|
||||||
|
content = connection.RetrieveContent()
|
||||||
|
vm = find_vm(content, vm_name)
|
||||||
|
vm.PowerOnVM_Task()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
logger.info(f"User {username} powered on VM {vm_name}")
|
||||||
|
|
||||||
|
return f"VM '{vm_name}' is powering on"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## MCP Transport: Streamable HTTP
|
||||||
|
|
||||||
### Environment Variables
|
OAuth requires HTTP transport (not stdio). Use Streamable HTTP:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In server.py or via environment
|
||||||
|
mcp = FastMCP(
|
||||||
|
"vSphere MCP Server",
|
||||||
|
auth=auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Run with HTTP transport for OAuth support
|
||||||
|
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Transport characteristics:**
|
||||||
|
- Single HTTP endpoint (`/mcp`)
|
||||||
|
- POST requests with JSON-RPC body
|
||||||
|
- Server-Sent Events (SSE) for streaming responses
|
||||||
|
- `Authorization: Bearer <token>` header on every request
|
||||||
|
- `Mcp-Session-Id` header for session continuity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# Authentik OIDC
|
||||||
# OAuth Configuration
|
AUTHENTIK_OIDC_URL=https://auth.example.com/application/o/vsphere-mcp/.well-known/openid-configuration
|
||||||
# ═══════════════════════════════════════════════════════════════
|
AUTHENTIK_CLIENT_ID=<from-authentik-application>
|
||||||
OAUTH_ENABLED=true
|
AUTHENTIK_CLIENT_SECRET=<from-authentik-application>
|
||||||
OAUTH_ISSUER_URL=https://auth.example.com/application/o/mcvsphere/
|
|
||||||
OAUTH_CLIENT_ID=<from-oidc-provider>
|
|
||||||
OAUTH_CLIENT_SECRET=<from-oidc-provider>
|
|
||||||
OAUTH_BASE_URL=https://mcp.example.com # Public URL for callbacks
|
|
||||||
OAUTH_SCOPES='["openid", "profile", "email", "groups"]'
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# MCP Server
|
||||||
# Transport (must be HTTP for OAuth)
|
MCP_BASE_URL=https://mcp.example.com # Public URL for OAuth redirects
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
MCP_TRANSPORT=streamable-http
|
MCP_TRANSPORT=streamable-http
|
||||||
MCP_HOST=0.0.0.0
|
|
||||||
MCP_PORT=8080
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# vCenter Connection (service account fallback)
|
||||||
# vCenter Connection (service account)
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
VCENTER_HOST=vcenter.example.com
|
VCENTER_HOST=vcenter.example.com
|
||||||
VCENTER_USER=svc-mcvsphere@vsphere.local
|
VCENTER_USER=svc-mcp@vsphere.local
|
||||||
VCENTER_PASSWORD=<service-account-password>
|
VCENTER_PASSWORD=<service-account-password>
|
||||||
VCENTER_INSECURE=false
|
VCENTER_INSECURE=true
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# Per-user credentials (optional, for testing)
|
||||||
# Optional
|
VCENTER_PASSWORD_RYAN=<ryan's-vcenter-password>
|
||||||
# ═══════════════════════════════════════════════════════════════
|
VCENTER_PASSWORD_ALICE=<alice's-vcenter-password>
|
||||||
LOG_LEVEL=INFO
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server Startup Banner
|
# User Mapping Mode
|
||||||
|
USER_MAPPING_MODE=service_account # or 'per_user', 'ldap_sync'
|
||||||
When OAuth is enabled, the server shows:
|
|
||||||
```
|
|
||||||
mcvsphere v0.2.1
|
|
||||||
────────────────────────────────────────
|
|
||||||
Starting HTTP transport on 0.0.0.0:8080
|
|
||||||
OAuth: ENABLED via https://auth.example.com/application/o/mcvsphere/
|
|
||||||
RBAC: ENABLED - permissions enforced via groups
|
|
||||||
────────────────────────────────────────
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## OIDC Provider Setup
|
## Authentik Setup (Quick Reference)
|
||||||
|
|
||||||
### Authentik (Recommended)
|
|
||||||
|
|
||||||
1. **Create OAuth2/OIDC Provider:**
|
1. **Create OAuth2/OIDC Provider:**
|
||||||
- Name: `mcvsphere`
|
- Name: `vsphere-mcp`
|
||||||
- Client Type: **Confidential**
|
- Client Type: Confidential
|
||||||
- Redirect URIs:
|
- Redirect URIs:
|
||||||
- `http://localhost:*/callback` (for local dev)
|
- `http://localhost:*/callback`
|
||||||
- `https://mcp.example.com/auth/callback`
|
- `https://mcp.example.com/auth/callback`
|
||||||
- Signing Key: Select RS256 certificate
|
- Scopes: `openid`, `profile`, `email`
|
||||||
|
- Signing Key: Select or create RS256 key
|
||||||
|
|
||||||
2. **Create Application:**
|
2. **Create Application:**
|
||||||
- Name: `mcvsphere`
|
- Name: `vSphere MCP Server`
|
||||||
- Slug: `mcvsphere`
|
- Slug: `vsphere-mcp`
|
||||||
- Provider: Select provider from step 1
|
- Provider: Select the provider above
|
||||||
|
- Note the **Client ID** and **Client Secret**
|
||||||
|
|
||||||
3. **Create Groups:**
|
3. **Configure Groups (optional):**
|
||||||
- `vsphere-readers`
|
- `vsphere-admins` - Full access
|
||||||
- `vsphere-operators`
|
- `vsphere-operators` - Limited access
|
||||||
- `vsphere-admins`
|
- Groups are included in JWT `groups` claim
|
||||||
- `vsphere-host-admins`
|
|
||||||
- `vsphere-super-admins`
|
|
||||||
|
|
||||||
4. **Add Scope Mapping for Groups:**
|
|
||||||
- Ensure `groups` claim is included in tokens
|
|
||||||
- Authentik includes this by default
|
|
||||||
|
|
||||||
5. **Note Credentials:**
|
|
||||||
- Copy Client ID and Client Secret
|
|
||||||
- Discovery URL: `https://auth.example.com/application/o/mcvsphere/.well-known/openid-configuration`
|
|
||||||
|
|
||||||
### Other Providers
|
|
||||||
|
|
||||||
The same pattern works with Keycloak, Auth0, Okta, etc. Key requirements:
|
|
||||||
- OIDC Discovery endpoint (`.well-known/openid-configuration`)
|
|
||||||
- JWT access tokens (not opaque)
|
|
||||||
- `groups` claim in tokens with group names
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## OAuth Flow
|
## OAuth Flow (End-to-End)
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Client connects to mcvsphere
|
1. Claude Code connects to MCP Server
|
||||||
→ POST /mcp (no auth)
|
→ GET /mcp
|
||||||
→ Server returns 401 + OAuth metadata URL
|
→ Server returns 401 Unauthorized
|
||||||
|
→ WWW-Authenticate header includes OAuth metadata URL
|
||||||
|
|
||||||
2. Client initiates OAuth flow
|
2. Claude Code fetches OAuth metadata
|
||||||
→ Opens browser to OIDC provider
|
→ Discovers Authentik authorization URL
|
||||||
→ User logs in
|
→ Discovers required scopes
|
||||||
→ Provider redirects with authorization code
|
|
||||||
|
|
||||||
3. Client exchanges code for tokens
|
3. Claude Code initiates OAuth flow
|
||||||
→ POST to provider token endpoint
|
→ Opens browser to Authentik login page
|
||||||
→ Receives JWT access token
|
→ User enters credentials
|
||||||
|
→ Authentik redirects back with authorization code
|
||||||
|
|
||||||
4. Client reconnects with token
|
4. Claude Code exchanges code for tokens
|
||||||
|
→ POST to Authentik token endpoint
|
||||||
|
→ Receives JWT access token + refresh token
|
||||||
|
|
||||||
|
5. Claude Code reconnects with Bearer token
|
||||||
→ POST /mcp with Authorization: Bearer <jwt>
|
→ POST /mcp with Authorization: Bearer <jwt>
|
||||||
→ Server validates JWT via JWKS
|
→ Server validates JWT via Authentik JWKS
|
||||||
→ Server extracts user + groups
|
→ Server extracts user identity
|
||||||
→ RBACMiddleware checks permissions
|
→ User can now invoke tools
|
||||||
→ User can invoke allowed tools
|
|
||||||
|
|
||||||
5. Tool invocation
|
6. Tool invocation
|
||||||
→ Client: "power on web-server"
|
→ Client: "power on web-server VM"
|
||||||
→ Middleware: Validate user has POWER_OPS
|
→ Server: Validates token, maps user to vCenter creds
|
||||||
→ Tool: Execute pyvmomi call
|
→ Server: Executes pyvmomi call
|
||||||
→ Audit: Log with user identity
|
→ Server: Logs "User ryan@example.com powered on web-server"
|
||||||
→ Client: Receive response
|
→ Client: Receives success response
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Status
|
## Implementation Checklist
|
||||||
|
|
||||||
### Completed
|
### Phase 1: Prepare Server
|
||||||
|
- [ ] Add `fastmcp[auth]` to dependencies
|
||||||
|
- [ ] Create `auth.py` with OIDCProxy configuration
|
||||||
|
- [ ] Create `credential_broker.py` for user mapping
|
||||||
|
- [ ] Add audit logging to all tools
|
||||||
|
- [ ] Update server.py to use HTTP transport
|
||||||
|
|
||||||
- [x] OIDCProxy configuration (`auth.py`)
|
### Phase 2: Deploy Authentik
|
||||||
- [x] Permission levels and tool mappings (`permissions.py`)
|
- [ ] Docker Compose for Authentik
|
||||||
- [x] RBACMiddleware with FastMCP integration (`middleware.py`)
|
- [ ] Create OIDC provider and application
|
||||||
- [x] Audit logging with user context (`audit.py`)
|
- [ ] Configure redirect URIs
|
||||||
- [x] Server integration with OAuth + RBAC (`server.py`)
|
- [ ] Note client credentials
|
||||||
- [x] Startup banner showing OAuth/RBAC status
|
- [ ] Test OIDC flow manually with curl
|
||||||
- [x] Security fix: deny-by-default for no groups
|
|
||||||
- [x] Authentik setup with 5 vsphere-* groups
|
|
||||||
|
|
||||||
### Future Enhancements
|
### Phase 3: Integration
|
||||||
|
- [ ] Configure environment variables
|
||||||
|
- [ ] Test with `fastmcp dev` (OAuth mode)
|
||||||
|
- [ ] Test with Claude Code (`claude mcp add --auth oauth`)
|
||||||
|
- [ ] Verify audit logs show correct user identity
|
||||||
|
|
||||||
- [ ] Per-user vCenter credential mapping (Vault integration)
|
### Phase 4: Production
|
||||||
- [ ] Rate limiting per user
|
- [ ] HTTPS via Caddy reverse proxy
|
||||||
- [ ] Session management and token refresh
|
- [ ] Secrets in Docker secrets / Vault
|
||||||
- [ ] Admin tools for permission management
|
- [ ] Service account with minimal vCenter permissions
|
||||||
- [ ] Prometheus metrics for RBAC decisions
|
- [ ] Log aggregation and monitoring
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security Considerations
|
## Files to Create/Modify
|
||||||
|
|
||||||
1. **Default Deny**: Users without recognized groups get NO access
|
```
|
||||||
2. **Token Validation**: JWTs validated via OIDC provider's JWKS endpoint
|
esxi-mcp-server/
|
||||||
3. **Audit Trail**: All operations logged with user identity
|
├── src/esxi_mcp_server/
|
||||||
4. **Secrets**: Client secrets should be stored securely (env vars, Docker secrets, Vault)
|
│ ├── auth.py # NEW: OIDCProxy configuration
|
||||||
5. **HTTPS**: Production deployments should use TLS (via Caddy, nginx, etc.)
|
│ ├── credential_broker.py # NEW: OAuth → vCenter credential mapping
|
||||||
6. **Service Account**: Use minimal vCenter permissions for the service account
|
│ ├── server.py # MODIFY: Add auth, HTTP transport
|
||||||
|
│ └── mixins/
|
||||||
|
│ └── *.py # MODIFY: Add ctx.request_context.user logging
|
||||||
|
├── .env.example # MODIFY: Add OAuth variables
|
||||||
|
└── docker-compose.yml # MODIFY: Add Authentik services
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Key Insight
|
||||||
|
|
||||||
### "401 Unauthorized" on all requests
|
The "middleman" role of the MCP server is critical:
|
||||||
- Check `OAUTH_ISSUER_URL` points to valid OIDC discovery endpoint
|
|
||||||
- Verify client ID and secret match provider configuration
|
|
||||||
- Ensure token hasn't expired
|
|
||||||
|
|
||||||
### "Permission denied" errors
|
```
|
||||||
- Check user's group memberships in OIDC provider
|
OAuth Token (Authentik) ──┐
|
||||||
- Verify groups claim is included in JWT (decode at jwt.io)
|
│
|
||||||
- Confirm group names match exactly (e.g., `vsphere-admins` not `vsphere_admins`)
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ MCP Server │ ← Validates OAuth, maps to vCenter creds
|
||||||
|
│ (Middleman) │ ← Logs audit trail with OAuth identity
|
||||||
|
└─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
vCenter API (pyvmomi)
|
||||||
|
```
|
||||||
|
|
||||||
### Token validation fails
|
The MCP server doesn't pass OAuth tokens to vCenter. Instead, it:
|
||||||
- Ensure OIDC provider issues JWTs (not opaque tokens)
|
1. **Authenticates** users via OAuth (trusts Authentik)
|
||||||
- Check signing key is configured in provider
|
2. **Authorizes** by mapping OAuth identity to vCenter credentials
|
||||||
- Verify `OAUTH_BASE_URL` matches redirect URI in provider
|
3. **Audits** by logging all actions with the OAuth user identity
|
||||||
|
4. **Executes** vCenter API calls using mapped credentials
|
||||||
|
|
||||||
### Audit logs not showing user
|
This gives you SSO-like experience while working within vCenter 7.0.3's authentication limitations.
|
||||||
- Check `groups` scope is requested
|
|
||||||
- Verify token contains `preferred_username` or `email` claim
|
|
||||||
|
|||||||
32
README.md
32
README.md
@ -195,41 +195,11 @@ Claude: [snapshots 12 VMs in parallel]
|
|||||||
| `VCENTER_CLUSTER` | Target cluster | *auto-detect* |
|
| `VCENTER_CLUSTER` | Target cluster | *auto-detect* |
|
||||||
| `VCENTER_DATASTORE` | Default datastore | *auto-detect* |
|
| `VCENTER_DATASTORE` | Default datastore | *auto-detect* |
|
||||||
| `VCENTER_NETWORK` | Default network | *auto-detect* |
|
| `VCENTER_NETWORK` | Default network | *auto-detect* |
|
||||||
| `MCP_TRANSPORT` | `stdio` or `streamable-http` | `stdio` |
|
| `MCP_TRANSPORT` | `stdio` or `sse` | `stdio` |
|
||||||
| `LOG_LEVEL` | Logging verbosity | `INFO` |
|
| `LOG_LEVEL` | Logging verbosity | `INFO` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Multi-User / OAuth Mode
|
|
||||||
|
|
||||||
For shared infrastructure or production deployments, mcvsphere supports OAuth 2.1 with any OIDC provider (Authentik, Keycloak, Auth0, etc.):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable HTTP transport with OAuth
|
|
||||||
export MCP_TRANSPORT=streamable-http
|
|
||||||
export OAUTH_ENABLED=true
|
|
||||||
export OAUTH_ISSUER_URL=https://auth.example.com/application/o/mcvsphere/
|
|
||||||
export OAUTH_CLIENT_ID=your-client-id
|
|
||||||
export OAUTH_CLIENT_SECRET=your-client-secret
|
|
||||||
export OAUTH_BASE_URL=https://mcp.example.com
|
|
||||||
|
|
||||||
uvx mcvsphere
|
|
||||||
```
|
|
||||||
|
|
||||||
Users authenticate via browser, and group memberships map to permission levels:
|
|
||||||
|
|
||||||
| Group | Access |
|
|
||||||
|-------|--------|
|
|
||||||
| `vsphere-super-admins` | Full control (all 94 tools) |
|
|
||||||
| `vsphere-host-admins` | Host operations + VM management |
|
|
||||||
| `vsphere-admins` | VM lifecycle management |
|
|
||||||
| `vsphere-operators` | Power ops + snapshots |
|
|
||||||
| `vsphere-readers` | Read-only |
|
|
||||||
|
|
||||||
See [OAUTH-ARCHITECTURE.md](OAUTH-ARCHITECTURE.md) for detailed setup instructions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -235,89 +235,15 @@ MCP_LOG_LEVEL=DEBUG
|
|||||||
log_level: "DEBUG"
|
log_level: "DEBUG"
|
||||||
```
|
```
|
||||||
|
|
||||||
## OAuth Multi-User Mode
|
|
||||||
|
|
||||||
For shared infrastructure or production deployments, mcvsphere supports OAuth 2.1 with any OIDC provider. This enables:
|
|
||||||
|
|
||||||
- **Browser-based authentication** via Authentik, Keycloak, Auth0, Okta, etc.
|
|
||||||
- **Group-based RBAC** with 5 permission levels
|
|
||||||
- **Audit logging** with user identity
|
|
||||||
|
|
||||||
### Quick Start (Existing OIDC Provider)
|
|
||||||
|
|
||||||
If you already have an OIDC provider:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Copy and configure environment
|
|
||||||
cp .env.oauth.example .env
|
|
||||||
# Edit .env with your OIDC provider details
|
|
||||||
|
|
||||||
# 2. Start mcvsphere with OAuth
|
|
||||||
docker compose -f docker-compose.oauth-standalone.yml up -d
|
|
||||||
|
|
||||||
# 3. Add to Claude Code
|
|
||||||
claude mcp add -t http vsphere https://mcp.example.com/mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
### Quick Start (New Authentik Deployment)
|
|
||||||
|
|
||||||
To deploy mcvsphere with a complete Authentik identity provider:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Generate secrets and configure
|
|
||||||
./scripts/setup-oauth.sh
|
|
||||||
|
|
||||||
# 2. Start full stack (mcvsphere + Authentik + PostgreSQL + Redis)
|
|
||||||
docker compose -f docker-compose.oauth.yml up -d
|
|
||||||
|
|
||||||
# 3. Configure Authentik at https://auth.yourdomain.com
|
|
||||||
# - Create OAuth2 provider
|
|
||||||
# - Create vsphere-* groups
|
|
||||||
# - Assign users to groups
|
|
||||||
```
|
|
||||||
|
|
||||||
### RBAC Permission Groups
|
|
||||||
|
|
||||||
Create these groups in your OIDC provider:
|
|
||||||
|
|
||||||
| Group | Access Level |
|
|
||||||
|-------|--------------|
|
|
||||||
| `vsphere-super-admins` | Full control (all 94 tools) |
|
|
||||||
| `vsphere-host-admins` | Host operations + VM management |
|
|
||||||
| `vsphere-admins` | VM lifecycle management |
|
|
||||||
| `vsphere-operators` | Power ops + snapshots |
|
|
||||||
| `vsphere-readers` | Read-only |
|
|
||||||
|
|
||||||
Users without any `vsphere-*` group are denied access (default-deny security).
|
|
||||||
|
|
||||||
### Required Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Transport (must be streamable-http for OAuth)
|
|
||||||
MCP_TRANSPORT=streamable-http
|
|
||||||
|
|
||||||
# OIDC Provider
|
|
||||||
OAUTH_ENABLED=true
|
|
||||||
OAUTH_ISSUER_URL=https://auth.example.com/application/o/mcvsphere/
|
|
||||||
OAUTH_CLIENT_ID=your-client-id
|
|
||||||
OAUTH_CLIENT_SECRET=your-client-secret
|
|
||||||
OAUTH_BASE_URL=https://mcp.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
See [OAUTH-ARCHITECTURE.md](OAUTH-ARCHITECTURE.md) for detailed setup and troubleshooting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
|
|
||||||
### Security Recommendations
|
### Security Recommendations
|
||||||
|
|
||||||
1. Use a dedicated service account for vCenter access
|
1. Use a dedicated user account for vCenter access
|
||||||
2. Enable OAuth authentication (not API keys)
|
2. Enable API key authentication
|
||||||
3. Use valid SSL certificates (set `VCENTER_INSECURE=false`)
|
3. Use valid SSL certificates (set `insecure: false`)
|
||||||
4. Limit container resources
|
4. Limit container resources
|
||||||
5. Use Docker secrets for sensitive data
|
5. Use Docker secrets for sensitive data
|
||||||
6. Deploy behind a reverse proxy with HTTPS (Caddy recommended)
|
|
||||||
|
|
||||||
### High Availability
|
### High Availability
|
||||||
|
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
# mcvsphere with OAuth - Standalone Mode
|
|
||||||
# For users who already have an OIDC provider (Authentik, Keycloak, Auth0, Okta, etc.)
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# 1. Copy .env.oauth.example to .env
|
|
||||||
# 2. Configure your OIDC provider settings
|
|
||||||
# 3. docker compose -f docker-compose.oauth-standalone.yml up -d
|
|
||||||
#
|
|
||||||
# Requires:
|
|
||||||
# - External OIDC provider with OAuth 2.1 support
|
|
||||||
# - Caddy network (caddy-docker-proxy) for HTTPS termination
|
|
||||||
#
|
|
||||||
# For full Authentik deployment, use docker-compose.oauth.yml instead
|
|
||||||
|
|
||||||
services:
|
|
||||||
mcvsphere:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: mcvsphere:${MCVSPHERE_VERSION:-latest}
|
|
||||||
container_name: mcvsphere
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
# Transport - streamable-http required for OAuth
|
|
||||||
MCP_TRANSPORT: streamable-http
|
|
||||||
MCP_HOST: 0.0.0.0
|
|
||||||
MCP_PORT: 8080
|
|
||||||
# OAuth - set in .env file
|
|
||||||
OAUTH_ENABLED: ${OAUTH_ENABLED:-true}
|
|
||||||
volumes:
|
|
||||||
- ./logs:/app/logs
|
|
||||||
networks:
|
|
||||||
- caddy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/.well-known/oauth-authorization-server')"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 15s
|
|
||||||
labels:
|
|
||||||
# Caddy reverse proxy - configure domain in .env
|
|
||||||
caddy: ${MCP_DOMAIN:-mcp.localhost}
|
|
||||||
caddy.reverse_proxy: "{{upstreams 8080}}"
|
|
||||||
# TLS - use 'internal' for local dev, remove for production (auto HTTPS)
|
|
||||||
caddy.tls: ${MCP_TLS_MODE:-internal}
|
|
||||||
# WebSocket/streaming support
|
|
||||||
caddy.reverse_proxy.flush_interval: "-1"
|
|
||||||
caddy.reverse_proxy.transport: http
|
|
||||||
caddy.reverse_proxy.transport.read_timeout: "0"
|
|
||||||
caddy.reverse_proxy.transport.write_timeout: "0"
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 512M
|
|
||||||
cpus: '1.0'
|
|
||||||
reservations:
|
|
||||||
memory: 256M
|
|
||||||
cpus: '0.25'
|
|
||||||
|
|
||||||
networks:
|
|
||||||
caddy:
|
|
||||||
external: true
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcvsphere"
|
name = "mcvsphere"
|
||||||
version = "0.2.2"
|
version = "0.2.1"
|
||||||
description = "Model Control for vSphere - AI-driven VMware virtual machine management via MCP"
|
description = "Model Control for vSphere - AI-driven VMware virtual machine management via MCP"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@ -3,18 +3,12 @@
|
|||||||
Provides decorators and hooks for wrapping tool execution with:
|
Provides decorators and hooks for wrapping tool execution with:
|
||||||
- OAuth permission validation
|
- OAuth permission validation
|
||||||
- Audit logging with user identity
|
- Audit logging with user identity
|
||||||
- FastMCP middleware integration for RBAC
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
import mcp.types as mt
|
|
||||||
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
|
|
||||||
from fastmcp.tools.tool import ToolResult
|
|
||||||
|
|
||||||
from mcvsphere.audit import (
|
from mcvsphere.audit import (
|
||||||
audit_log,
|
audit_log,
|
||||||
@ -30,11 +24,6 @@ from mcvsphere.permissions import (
|
|||||||
get_required_permission,
|
get_required_permission,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from fastmcp.server.context import Context
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def with_permission_check(tool_name: str) -> Callable:
|
def with_permission_check(tool_name: str) -> Callable:
|
||||||
"""Decorator factory for permission checking and audit logging.
|
"""Decorator factory for permission checking and audit logging.
|
||||||
@ -92,26 +81,24 @@ def with_permission_check(tool_name: str) -> Callable:
|
|||||||
def extract_user_from_context(ctx) -> dict[str, Any] | None:
|
def extract_user_from_context(ctx) -> dict[str, Any] | None:
|
||||||
"""Extract user information from FastMCP context.
|
"""Extract user information from FastMCP context.
|
||||||
|
|
||||||
Uses FastMCP's dependency injection to get the access token from
|
|
||||||
the current request's context variable.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: FastMCP Context object (unused, kept for API compatibility).
|
ctx: FastMCP Context object.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User info dict from OAuth token claims, or None if not authenticated.
|
User info dict from OAuth token claims, or None if not authenticated.
|
||||||
"""
|
"""
|
||||||
try:
|
if ctx is None:
|
||||||
# FastMCP stores access token in a context variable, accessed via dependency
|
return None
|
||||||
from fastmcp.server.dependencies import get_access_token
|
|
||||||
|
|
||||||
access_token = get_access_token()
|
# Try to get access token from context
|
||||||
if access_token and hasattr(access_token, "claims"):
|
try:
|
||||||
return access_token.claims
|
# FastMCP stores the access token in request_context
|
||||||
except (RuntimeError, ImportError) as e:
|
if hasattr(ctx, "request_context") and ctx.request_context:
|
||||||
# RuntimeError: No active HTTP request context
|
token = getattr(ctx.request_context, "access_token", None)
|
||||||
# ImportError: FastMCP auth dependencies not available
|
if token and hasattr(token, "claims"):
|
||||||
logger.debug("Could not get access token: %s", e)
|
return token.claims
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -192,154 +179,3 @@ def get_permission_summary() -> dict[str, list[str]]:
|
|||||||
summary[level].sort()
|
summary[level].sort()
|
||||||
|
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
class RBACMiddleware(Middleware):
|
|
||||||
"""FastMCP middleware for Role-Based Access Control.
|
|
||||||
|
|
||||||
Integrates with FastMCP's middleware system to enforce permissions
|
|
||||||
on every tool call based on OAuth group memberships.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
mcp = FastMCP("my-server", auth=oauth_provider)
|
|
||||||
mcp.add_middleware(RBACMiddleware())
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _extract_user_from_context(
|
|
||||||
self, fastmcp_ctx: "Context | None"
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Extract user claims from FastMCP context.
|
|
||||||
|
|
||||||
Uses FastMCP's dependency injection to retrieve the access token
|
|
||||||
from the current request's context variable.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
fastmcp_ctx: FastMCP Context object (unused, kept for API compatibility).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User claims dict from OAuth token, or None if not authenticated.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# FastMCP stores access token in a context variable, accessed via dependency
|
|
||||||
from fastmcp.server.dependencies import get_access_token
|
|
||||||
|
|
||||||
access_token = get_access_token()
|
|
||||||
if access_token and hasattr(access_token, "claims"):
|
|
||||||
return access_token.claims
|
|
||||||
except (RuntimeError, ImportError) as e:
|
|
||||||
# RuntimeError: No active HTTP request context
|
|
||||||
# ImportError: FastMCP auth dependencies not available
|
|
||||||
logger.debug("Could not get access token: %s", e)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_username(self, claims: dict[str, Any] | None) -> str:
|
|
||||||
"""Extract username from OAuth claims.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
claims: OAuth token claims dict.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Username string, or 'anonymous' if no claims.
|
|
||||||
"""
|
|
||||||
if not claims:
|
|
||||||
return "anonymous"
|
|
||||||
|
|
||||||
for claim in ("preferred_username", "email", "sub"):
|
|
||||||
if value := claims.get(claim):
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
def _get_groups(self, claims: dict[str, Any] | None) -> list[str]:
|
|
||||||
"""Extract groups from OAuth claims.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
claims: OAuth token claims dict.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of group names, or empty list if no claims.
|
|
||||||
"""
|
|
||||||
if not claims:
|
|
||||||
return []
|
|
||||||
|
|
||||||
groups = claims.get("groups", [])
|
|
||||||
if isinstance(groups, list):
|
|
||||||
return groups
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def on_call_tool(
|
|
||||||
self,
|
|
||||||
context: MiddlewareContext[mt.CallToolRequestParams],
|
|
||||||
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
|
|
||||||
) -> ToolResult:
|
|
||||||
"""Intercept tool calls to enforce RBAC permissions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: Middleware context containing tool call params.
|
|
||||||
call_next: Next handler in the middleware chain.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tool result if permitted.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
PermissionDeniedError: If user lacks required permission.
|
|
||||||
"""
|
|
||||||
# Extract tool name and arguments from the request
|
|
||||||
tool_name = context.message.name
|
|
||||||
tool_args = context.message.arguments or {}
|
|
||||||
|
|
||||||
# Get user info from OAuth context
|
|
||||||
claims = self._extract_user_from_context(context.fastmcp_context)
|
|
||||||
username = self._get_username(claims)
|
|
||||||
groups = self._get_groups(claims)
|
|
||||||
|
|
||||||
# Set up audit context for this request
|
|
||||||
set_current_user(claims)
|
|
||||||
|
|
||||||
# Check permission
|
|
||||||
if not check_permission(tool_name, groups):
|
|
||||||
required = get_required_permission(tool_name)
|
|
||||||
logger.warning(
|
|
||||||
"Permission denied: user=%s groups=%s tool=%s required=%s",
|
|
||||||
username,
|
|
||||||
groups,
|
|
||||||
tool_name,
|
|
||||||
required.value,
|
|
||||||
)
|
|
||||||
audit_permission_denied(tool_name, tool_args, required.value)
|
|
||||||
raise PermissionDeniedError(username, tool_name, required)
|
|
||||||
|
|
||||||
# Permission granted - execute tool with timing
|
|
||||||
start_time = time.perf_counter()
|
|
||||||
try:
|
|
||||||
result = await call_next(context)
|
|
||||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
||||||
|
|
||||||
# Audit successful execution
|
|
||||||
# ToolResult can be complex, just log that it succeeded
|
|
||||||
audit_log(tool_name, tool_args, result="success", duration_ms=duration_ms)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Tool executed: user=%s tool=%s duration=%.2fms",
|
|
||||||
username,
|
|
||||||
tool_name,
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except PermissionDeniedError:
|
|
||||||
# Re-raise without additional logging
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
||||||
audit_log(tool_name, tool_args, error=str(e), duration_ms=duration_ms)
|
|
||||||
logger.error(
|
|
||||||
"Tool failed: user=%s tool=%s error=%s duration=%.2fms",
|
|
||||||
username,
|
|
||||||
tool_name,
|
|
||||||
str(e),
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|||||||
@ -120,11 +120,10 @@ TOOL_PERMISSIONS: dict[str, PermissionLevel] = {
|
|||||||
"configure_ntp": PermissionLevel.HOST_ADMIN,
|
"configure_ntp": PermissionLevel.HOST_ADMIN,
|
||||||
"set_service_policy": PermissionLevel.HOST_ADMIN,
|
"set_service_policy": PermissionLevel.HOST_ADMIN,
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
# FULL_ADMIN - Everything including guest OS and service control (11 tools)
|
# FULL_ADMIN - Everything including guest OS and service control (10 tools)
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
"start_service": PermissionLevel.FULL_ADMIN,
|
"start_service": PermissionLevel.FULL_ADMIN,
|
||||||
"stop_service": PermissionLevel.FULL_ADMIN,
|
"stop_service": PermissionLevel.FULL_ADMIN,
|
||||||
"restart_service": PermissionLevel.FULL_ADMIN,
|
|
||||||
# Guest OS operations (requires guest credentials, high privilege)
|
# Guest OS operations (requires guest credentials, high privilege)
|
||||||
"run_command_in_guest": PermissionLevel.FULL_ADMIN,
|
"run_command_in_guest": PermissionLevel.FULL_ADMIN,
|
||||||
"list_guest_processes": PermissionLevel.FULL_ADMIN,
|
"list_guest_processes": PermissionLevel.FULL_ADMIN,
|
||||||
@ -192,10 +191,9 @@ def get_user_permissions(groups: list[str] | None) -> set[PermissionLevel]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Set of granted permission levels (union of all group permissions).
|
Set of granted permission levels (union of all group permissions).
|
||||||
Returns empty set if no recognized groups (deny all access).
|
|
||||||
"""
|
"""
|
||||||
if not groups:
|
if not groups:
|
||||||
return set() # No groups = no permissions (enforces RBAC)
|
return {PermissionLevel.READ_ONLY}
|
||||||
|
|
||||||
permissions: set[PermissionLevel] = set()
|
permissions: set[PermissionLevel] = set()
|
||||||
|
|
||||||
@ -203,7 +201,10 @@ def get_user_permissions(groups: list[str] | None) -> set[PermissionLevel]:
|
|||||||
if group in GROUP_PERMISSIONS:
|
if group in GROUP_PERMISSIONS:
|
||||||
permissions.update(GROUP_PERMISSIONS[group])
|
permissions.update(GROUP_PERMISSIONS[group])
|
||||||
|
|
||||||
# No fallback - unrecognized groups get no permissions
|
# Default to read-only if no recognized groups
|
||||||
|
if not permissions:
|
||||||
|
permissions.add(PermissionLevel.READ_ONLY)
|
||||||
|
|
||||||
return permissions
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,6 @@ from fastmcp import FastMCP
|
|||||||
from mcvsphere.auth import create_auth_provider
|
from mcvsphere.auth import create_auth_provider
|
||||||
from mcvsphere.config import Settings, get_settings
|
from mcvsphere.config import Settings, get_settings
|
||||||
from mcvsphere.connection import VMwareConnection
|
from mcvsphere.connection import VMwareConnection
|
||||||
from mcvsphere.middleware import RBACMiddleware
|
|
||||||
from mcvsphere.mixins import (
|
from mcvsphere.mixins import (
|
||||||
ConsoleMixin,
|
ConsoleMixin,
|
||||||
DiskManagementMixin,
|
DiskManagementMixin,
|
||||||
@ -69,11 +68,6 @@ def create_server(settings: Settings | None = None) -> FastMCP:
|
|||||||
auth=auth,
|
auth=auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add RBAC middleware when OAuth is enabled
|
|
||||||
if settings.oauth_enabled:
|
|
||||||
mcp.add_middleware(RBACMiddleware())
|
|
||||||
logger.info("RBAC middleware enabled - permissions enforced via OAuth groups")
|
|
||||||
|
|
||||||
# Create shared VMware connection
|
# Create shared VMware connection
|
||||||
logger.info("Connecting to VMware vCenter/ESXi...")
|
logger.info("Connecting to VMware vCenter/ESXi...")
|
||||||
conn = VMwareConnection(settings)
|
conn = VMwareConnection(settings)
|
||||||
@ -143,9 +137,8 @@ def run_server(config_path: Path | None = None) -> None:
|
|||||||
)
|
)
|
||||||
if settings.oauth_enabled:
|
if settings.oauth_enabled:
|
||||||
print(f"OAuth: ENABLED via {settings.oauth_issuer_url}", file=sys.stderr)
|
print(f"OAuth: ENABLED via {settings.oauth_issuer_url}", file=sys.stderr)
|
||||||
print("RBAC: ENABLED - permissions enforced via groups", file=sys.stderr)
|
|
||||||
else:
|
else:
|
||||||
print("OAuth: disabled (single-user mode)", file=sys.stderr)
|
print("OAuth: disabled", file=sys.stderr)
|
||||||
print("─" * 40, file=sys.stderr)
|
print("─" * 40, file=sys.stderr)
|
||||||
|
|
||||||
# Create and run server
|
# Create and run server
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user