Compare commits

..

No commits in common. "main" and "feature/oauth-authentication" have entirely different histories.

10 changed files with 287 additions and 701 deletions

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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