Compare commits

..

8 Commits

Author SHA1 Message Date
ebbf2c297c docs: add Docker Compose setup for OAuth multi-user mode
- Update Dockerfile to default to streamable-http transport
- Add docker-compose.oauth-standalone.yml for existing OIDC providers
- Add .env.oauth.example template with all required settings
- Update README_DOCKER.md with OAuth deployment instructions

OAuth mode requires streamable-http transport (not stdio) since
authentication happens via browser redirect flow.
2026-01-12 14:40:50 -07:00
1d9fcf73af release: v0.2.2 - OAuth claims extraction fix
- Fix OAuth user/group extraction using FastMCP's get_access_token()
- Update README with correct RBAC group names (vsphere-readers, not viewers)
- Add missing vsphere-host-admins group to documentation
2025-12-28 12:50:57 -07:00
79d2caef45 fix: use FastMCP get_access_token() for OAuth claims extraction
The previous implementation tried to access OAuth token claims via
context.request_context.access_token.claims, which doesn't exist in
FastMCP's context structure.

FastMCP stores access tokens in Python's ContextVars, accessible via
the get_access_token() dependency function. This fix updates both
extract_user_from_context() and RBACMiddleware._extract_user_from_context()
to use this correct approach.

Before: Users appeared as "anonymous" with no groups (RBAC denied all)
After: User identity and groups correctly extracted from OAuth claims
2025-12-28 11:15:28 -07:00
ab83c70c31 docs: update OAuth/RBAC architecture documentation
Rewrites the architecture doc from design proposal to implementation
reference. Documents the complete RBAC system including:

- 5 permission levels (READ_ONLY → FULL_ADMIN)
- 5 OAuth groups with permission mappings
- RBACMiddleware implementation details
- Audit log format with user identity
- Configuration environment variables
- OIDC provider setup (Authentik example)
- Troubleshooting guide for common issues

Updates implementation checklist to reflect completed status.
2025-12-27 08:22:02 -07:00
00857b1840 fix: enforce strict RBAC - deny access for users without groups
Security fix: Previously users with no OAuth groups or unrecognized
groups would default to READ_ONLY access. Now:
- Empty groups = no permissions (denied access to all tools)
- Unrecognized groups = no permissions (denied access)

Also adds missing restart_service mapping to FULL_ADMIN permission.
2025-12-27 08:10:01 -07:00
6159098963 feat: implement RBAC middleware for OAuth group-based permissions
Add RBACMiddleware that integrates with FastMCP's middleware system to
enforce role-based access control on all tool calls:

- Intercepts every tool call via on_call_tool() hook
- Extracts user groups from OAuth token claims
- Checks permissions using existing permissions.py mappings
- Logs all tool invocations with user identity via audit.py
- Denies access with clear PermissionDeniedError when unauthorized

Permission levels (from permissions.py):
- READ_ONLY: view operations (vsphere-readers)
- POWER_OPS: power/snapshot ops (vsphere-operators)
- VM_LIFECYCLE: create/delete VMs (vsphere-admins)
- HOST_ADMIN: ESXi host management (vsphere-host-admins)
- FULL_ADMIN: guest ops, services (vsphere-super-admins)

Middleware only enabled when OAuth is active (OAUTH_ENABLED=true).
STDIO mode continues to work without permission checking.
2025-12-27 07:37:26 -07:00
0e29fea857 docs: add OAuth multi-user mode to README
- Add Multi-User / OAuth Mode section with quick setup
- Document permission groups for RBAC
- Update transport option to streamable-http
- Link to OAUTH-ARCHITECTURE.md for details
2025-12-27 06:03:59 -07:00
4890950e19 Merge branch 'feature/oauth-authentication'
OAuth 2.1 + PKCE authentication for mcvsphere MCP server.

Features:
- OIDC integration via FastMCP's OIDCProxy
- Authentik identity provider support
- Dynamic Client Registration for MCP clients
- PKCE flow for secure authorization
- Permission groups mapped from Authentik groups
- Audit logging with user identity

Tested end-to-end with Claude Code CLI.
2025-12-27 05:54:57 -07:00
10 changed files with 701 additions and 287 deletions

82
.env.oauth.example Normal file
View File

@ -0,0 +1,82 @@
# 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,8 +48,11 @@ ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
# Default to SSE transport for Docker # Default transport - override with MCP_TRANSPORT env var
ENV MCP_TRANSPORT=sse # stdio: Direct CLI usage (default for local)
# 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
@ -58,10 +61,9 @@ USER mcpuser
EXPOSE 8080 EXPOSE 8080
# Health check # Health check - works with both SSE and streamable-http
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://localhost:8080')" || exit 1 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/.well-known/oauth-authorization-server')" || exit 1
# Run the MCP server # Run the MCP server - transport configured via environment
ENTRYPOINT ["mcvsphere"] ENTRYPOINT ["mcvsphere"]
CMD ["--transport", "sse"]

View File

@ -1,17 +1,17 @@
# OAuth Architecture for vSphere MCP Server # OAuth & RBAC Architecture for mcvsphere
## The Problem ## Overview
We need to add authentication to the MCP server so that: mcvsphere supports multi-user OAuth 2.1 authentication with Role-Based Access Control (RBAC). This enables:
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
**Challenge:** vCenter 7.0.3 doesn't support OAuth token exchange (RFC 8693), so we can't pass OAuth tokens directly to vCenter. 1. **Single Sign-On** via any OIDC provider (Authentik, Keycloak, Auth0, etc.)
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 Overview ## Architecture
``` ```
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
@ -22,360 +22,352 @@ We need to add authentication to the MCP server so that:
│ (browser opens for login) │ (browser opens for login)
┌────────────────────────────▼────────────────────────────────────┐ ┌────────────────────────────▼────────────────────────────────────┐
Authentik OIDC Provider
(Self-hosted OIDC IdP) (Authentik, Keycloak, Auth0, etc.)
│ │ │ │
│ - Issues JWT access tokens │ │ - Issues JWT access tokens │
│ - Validates user credentials │ │ - Validates user credentials │
│ - Includes user identity in token (sub, email, groups) │ - Includes groups claim in token
└────────────────────────────┬────────────────────────────────────┘ └────────────────────────────┬────────────────────────────────────┘
│ 2. JWT Bearer token │ 2. JWT Bearer token
│ Authorization: Bearer <jwt> │ Authorization: Bearer <jwt>
┌────────────────────────────▼────────────────────────────────────┐ ┌────────────────────────────▼────────────────────────────────────┐
vSphere MCP Server mcvsphere
│ (FastMCP + pyvmomi) │ │ (FastMCP + pyvmomi) │
│ │ │ │
│ ┌─────────────────────────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ OIDCProxy (FastMCP) │ │ │ │ OIDCProxy (FastMCP) │ │
│ │ - Validates JWT signature via Authentik JWKS │ │ │ │ - Validates JWT signature via JWKS endpoint │ │
│ │ - Extracts user identity (preferred_username) │ │ │ │ - Extracts user identity (preferred_username, email) │ │
│ │ - Makes user available via ctx.request_context.user │ │ │ │ - Extracts groups from token claims │ │
│ └─────────────────────────────────────────────────────────┘ │ │ └─────────────────────────────────────────────────────────┘ │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ Credential Broker │ │ │ │ RBACMiddleware │ │
│ │ - Maps OAuth user → vCenter credentials │ │ │ │ - Intercepts ALL tool calls via on_call_tool() │ │
│ │ - Caches pyvmomi connections per-user │ │ │ │ - Maps OAuth groups → Permission levels │ │
│ │ - Retrieves passwords from Vault / env vars │ │ │ │ - Denies access if user lacks required permission │ │
│ │ - Logs audit events with user identity │ │
│ └─────────────────────────────────────────────────────────┘ │ │ └─────────────────────────────────────────────────────────┘ │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ Audit Logger │ │ │ │ VMware Tools (94) │ │
│ │ - Logs all tool invocations with OAuth identity │ │ │ │ - Execute vCenter/ESXi operations via pyvmomi │ │
│ │ - "User ryan@example.com powered on VM web-server" │ │ │ │ - Single service account connection to vCenter │ │
│ └─────────────────────────────────────────────────────────┘ │ │ └─────────────────────────────────────────────────────────┘ │
└────────────────────────────┬────────────────────────────────────┘ └────────────────────────────┬────────────────────────────────────┘
│ 3. pyvmomi (as mapped user) │ 3. pyvmomi (service account)
┌────────────────────────────▼────────────────────────────────────┐ ┌────────────────────────────▼────────────────────────────────────┐
│ vCenter 7.0.3 │ │ vCenter / ESXi │
│ - Receives API calls as the actual user │ │ - Receives API calls as service account │
│ - Native audit logs show real user identity │ │ - mcvsphere audit logs show real user identity │
│ - vCenter permissions apply naturally │
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
``` ```
--- ---
## User Mapping Strategies ## RBAC Permission Model
Since we can't exchange OAuth tokens for vCenter tokens, we need a "credential broker": ### Permission Levels
| Strategy | How it Works | Security | Use Case | mcvsphere defines 5 permission levels, from least to most privileged:
|----------|--------------|----------|----------|
| **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 |
### Recommended: Per-User Mapping with Fallback | Level | Description | Example Tools |
|-------|-------------|---------------|
| `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
class CredentialBroker: # READ_ONLY - 32 tools
"""Maps OAuth users to vCenter credentials.""" "list_vms", "get_vm_info", "list_snapshots", "get_vm_stats", ...
def __init__(self, vcenter_host: str, fallback_user: str = None, fallback_password: str = None): # POWER_OPS - 14 tools
self.vcenter_host = vcenter_host "power_on", "power_off", "create_snapshot", "revert_to_snapshot", ...
self.fallback_user = fallback_user # Service account fallback
self.fallback_password = fallback_password
self._connections: dict[str, ServiceInstance] = {}
def get_connection_for_user(self, oauth_user: dict) -> ServiceInstance: # VM_LIFECYCLE - 33 tools
"""Get pyvmomi connection for this OAuth user.""" "create_vm", "clone_vm", "delete_vm", "add_disk", "deploy_ovf", ...
username = oauth_user.get("preferred_username")
# Try per-user credentials first # HOST_ADMIN - 6 tools
try: "enter_maintenance_mode", "reboot_host", "shutdown_host", ...
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}")
def _lookup_credentials(self, username: str) -> dict: # FULL_ADMIN - 11 tools
"""Look up vCenter credentials for OAuth user.""" "run_command_in_guest", "write_guest_file", "restart_service", ...
# 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}")
``` ```
--- ---
## FastMCP OAuth Integration ## Implementation Details
### 1. Add OIDCProxy to server.py ### Key Files
| 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
import os class RBACMiddleware(Middleware):
from fastmcp import FastMCP """Intercepts all tool calls to enforce permissions."""
from fastmcp.server.auth import OIDCProxy
# Configure OAuth with Authentik async def on_call_tool(self, context, call_next):
auth = OIDCProxy( # 1. Extract user from OAuth token
# Authentik OIDC Discovery URL claims = self._extract_user_from_context(context.fastmcp_context)
config_url=os.environ["AUTHENTIK_OIDC_URL"], username = claims.get("preferred_username", "unknown")
# e.g., "https://auth.example.com/application/o/vsphere-mcp/.well-known/openid-configuration" groups = claims.get("groups", [])
# Application credentials from Authentik # 2. Check permission
client_id=os.environ["AUTHENTIK_CLIENT_ID"], tool_name = context.message.name
client_secret=os.environ["AUTHENTIK_CLIENT_SECRET"], if not check_permission(tool_name, groups):
required = get_required_permission(tool_name)
audit_permission_denied(tool_name, {...}, required.value)
raise PermissionDeniedError(username, tool_name, required)
# MCP Server base URL (for redirects) # 3. Execute tool with timing
base_url=os.environ.get("MCP_BASE_URL", "http://localhost:8000"), start = time.perf_counter()
result = await call_next(context)
duration_ms = (time.perf_counter() - start) * 1000
# Token validation # 4. Audit log
required_scopes=["openid", "profile", "email"], audit_log(tool_name, {...}, result="success", duration_ms=duration_ms)
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
)
``` ```
### 2. Access User Identity in Tools ### Audit Log Format
```python ```json
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"
}
```
@mcp.tool() Permission denied events:
async def power_on_vm(ctx: Context, vm_name: str) -> str: ```json
"""Power on a virtual machine.""" {
# Get authenticated user from OAuth token "timestamp": "2025-12-27T08:15:32.123456+00:00",
user = ctx.request_context.user "user": "guest@example.com",
username = user.get("preferred_username", user.get("sub")) "groups": ["vsphere-readers"],
"tool": "delete_vm",
# Get vCenter connection for this user "args": {"vm_name": "web-server"},
broker = get_credential_broker() "required_permission": "vm_lifecycle",
connection = broker.get_connection_for_user(user) "event": "PERMISSION_DENIED"
}
# 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"
``` ```
--- ---
## MCP Transport: Streamable HTTP ## Configuration
OAuth requires HTTP transport (not stdio). Use Streamable HTTP: ### Environment Variables
```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 # ═══════════════════════════════════════════════════════════════
AUTHENTIK_OIDC_URL=https://auth.example.com/application/o/vsphere-mcp/.well-known/openid-configuration # OAuth Configuration
AUTHENTIK_CLIENT_ID=<from-authentik-application> # ═══════════════════════════════════════════════════════════════
AUTHENTIK_CLIENT_SECRET=<from-authentik-application> OAUTH_ENABLED=true
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 # ═══════════════════════════════════════════════════════════════
MCP_BASE_URL=https://mcp.example.com # Public URL for OAuth redirects # Transport (must be HTTP for OAuth)
# ═══════════════════════════════════════════════════════════════
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-mcp@vsphere.local VCENTER_USER=svc-mcvsphere@vsphere.local
VCENTER_PASSWORD=<service-account-password> VCENTER_PASSWORD=<service-account-password>
VCENTER_INSECURE=true VCENTER_INSECURE=false
# Per-user credentials (optional, for testing) # ═══════════════════════════════════════════════════════════════
VCENTER_PASSWORD_RYAN=<ryan's-vcenter-password> # Optional
VCENTER_PASSWORD_ALICE=<alice's-vcenter-password> # ═══════════════════════════════════════════════════════════════
LOG_LEVEL=INFO
```
# User Mapping Mode ### Server Startup Banner
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
────────────────────────────────────────
``` ```
--- ---
## Authentik Setup (Quick Reference) ## OIDC Provider Setup
### Authentik (Recommended)
1. **Create OAuth2/OIDC Provider:** 1. **Create OAuth2/OIDC Provider:**
- Name: `vsphere-mcp` - Name: `mcvsphere`
- Client Type: Confidential - Client Type: **Confidential**
- Redirect URIs: - Redirect URIs:
- `http://localhost:*/callback` - `http://localhost:*/callback` (for local dev)
- `https://mcp.example.com/auth/callback` - `https://mcp.example.com/auth/callback`
- Scopes: `openid`, `profile`, `email` - Signing Key: Select RS256 certificate
- Signing Key: Select or create RS256 key
2. **Create Application:** 2. **Create Application:**
- Name: `vSphere MCP Server` - Name: `mcvsphere`
- Slug: `vsphere-mcp` - Slug: `mcvsphere`
- Provider: Select the provider above - Provider: Select provider from step 1
- Note the **Client ID** and **Client Secret**
3. **Configure Groups (optional):** 3. **Create Groups:**
- `vsphere-admins` - Full access - `vsphere-readers`
- `vsphere-operators` - Limited access - `vsphere-operators`
- Groups are included in JWT `groups` claim - `vsphere-admins`
- `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 (End-to-End) ## OAuth Flow
``` ```
1. Claude Code connects to MCP Server 1. Client connects to mcvsphere
→ GET /mcp → POST /mcp (no auth)
→ Server returns 401 Unauthorized → Server returns 401 + OAuth metadata URL
→ WWW-Authenticate header includes OAuth metadata URL
2. Claude Code fetches OAuth metadata 2. Client initiates OAuth flow
→ Discovers Authentik authorization URL → Opens browser to OIDC provider
→ Discovers required scopes → User logs in
→ Provider redirects with authorization code
3. Claude Code initiates OAuth flow 3. Client exchanges code for tokens
→ Opens browser to Authentik login page → POST to provider token endpoint
→ User enters credentials → Receives JWT access token
→ Authentik redirects back with authorization code
4. Claude Code exchanges code for tokens 4. Client reconnects with token
→ 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 Authentik JWKS → Server validates JWT via JWKS
→ Server extracts user identity → Server extracts user + groups
→ User can now invoke tools → RBACMiddleware checks permissions
→ User can invoke allowed tools
6. Tool invocation 5. Tool invocation
→ Client: "power on web-server VM" → Client: "power on web-server"
Server: Validates token, maps user to vCenter creds Middleware: Validate user has POWER_OPS
Server: Executes pyvmomi call Tool: Execute pyvmomi call
Server: Logs "User ryan@example.com powered on web-server" Audit: Log with user identity
→ Client: Receives success response → Client: Receive response
``` ```
--- ---
## Implementation Checklist ## Implementation Status
### Phase 1: Prepare Server ### Completed
- [ ] 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
### Phase 2: Deploy Authentik - [x] OIDCProxy configuration (`auth.py`)
- [ ] Docker Compose for Authentik - [x] Permission levels and tool mappings (`permissions.py`)
- [ ] Create OIDC provider and application - [x] RBACMiddleware with FastMCP integration (`middleware.py`)
- [ ] Configure redirect URIs - [x] Audit logging with user context (`audit.py`)
- [ ] Note client credentials - [x] Server integration with OAuth + RBAC (`server.py`)
- [ ] Test OIDC flow manually with curl - [x] Startup banner showing OAuth/RBAC status
- [x] Security fix: deny-by-default for no groups
- [x] Authentik setup with 5 vsphere-* groups
### Phase 3: Integration ### Future Enhancements
- [ ] 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
### Phase 4: Production - [ ] Per-user vCenter credential mapping (Vault integration)
- [ ] HTTPS via Caddy reverse proxy - [ ] Rate limiting per user
- [ ] Secrets in Docker secrets / Vault - [ ] Session management and token refresh
- [ ] Service account with minimal vCenter permissions - [ ] Admin tools for permission management
- [ ] Log aggregation and monitoring - [ ] Prometheus metrics for RBAC decisions
--- ---
## Files to Create/Modify ## Security Considerations
``` 1. **Default Deny**: Users without recognized groups get NO access
esxi-mcp-server/ 2. **Token Validation**: JWTs validated via OIDC provider's JWKS endpoint
├── src/esxi_mcp_server/ 3. **Audit Trail**: All operations logged with user identity
│ ├── auth.py # NEW: OIDCProxy configuration 4. **Secrets**: Client secrets should be stored securely (env vars, Docker secrets, Vault)
│ ├── credential_broker.py # NEW: OAuth → vCenter credential mapping 5. **HTTPS**: Production deployments should use TLS (via Caddy, nginx, etc.)
│ ├── server.py # MODIFY: Add auth, HTTP transport 6. **Service Account**: Use minimal vCenter permissions for the service account
│ └── mixins/
│ └── *.py # MODIFY: Add ctx.request_context.user logging
├── .env.example # MODIFY: Add OAuth variables
└── docker-compose.yml # MODIFY: Add Authentik services
```
--- ---
## Key Insight ## Troubleshooting
The "middleman" role of the MCP server is critical: ### "401 Unauthorized" on all requests
- 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
OAuth Token (Authentik) ──┐ - Check user's group memberships in OIDC provider
- 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)
```
The MCP server doesn't pass OAuth tokens to vCenter. Instead, it: ### Token validation fails
1. **Authenticates** users via OAuth (trusts Authentik) - Ensure OIDC provider issues JWTs (not opaque tokens)
2. **Authorizes** by mapping OAuth identity to vCenter credentials - Check signing key is configured in provider
3. **Audits** by logging all actions with the OAuth user identity - Verify `OAUTH_BASE_URL` matches redirect URI in provider
4. **Executes** vCenter API calls using mapped credentials
This gives you SSO-like experience while working within vCenter 7.0.3's authentication limitations. ### Audit logs not showing user
- Check `groups` scope is requested
- Verify token contains `preferred_username` or `email` claim

View File

@ -195,11 +195,41 @@ 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 `sse` | `stdio` | | `MCP_TRANSPORT` | `stdio` or `streamable-http` | `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,15 +235,89 @@ 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 user account for vCenter access 1. Use a dedicated service account for vCenter access
2. Enable API key authentication 2. Enable OAuth authentication (not API keys)
3. Use valid SSL certificates (set `insecure: false`) 3. Use valid SSL certificates (set `VCENTER_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

@ -0,0 +1,64 @@
# 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.1" version = "0.2.2"
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,12 +3,18 @@
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 Any from typing import TYPE_CHECKING, 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,
@ -24,6 +30,11 @@ 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.
@ -81,24 +92,26 @@ 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. ctx: FastMCP Context object (unused, kept for API compatibility).
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.
""" """
if ctx is None:
return None
# Try to get access token from context
try: try:
# FastMCP stores the access token in request_context # FastMCP stores access token in a context variable, accessed via dependency
if hasattr(ctx, "request_context") and ctx.request_context: from fastmcp.server.dependencies import get_access_token
token = getattr(ctx.request_context, "access_token", None)
if token and hasattr(token, "claims"): access_token = get_access_token()
return token.claims if access_token and hasattr(access_token, "claims"):
except Exception: return access_token.claims
pass 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 return None
@ -179,3 +192,154 @@ 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,10 +120,11 @@ 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 (10 tools) # FULL_ADMIN - Everything including guest OS and service control (11 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,
@ -191,9 +192,10 @@ 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 {PermissionLevel.READ_ONLY} return set() # No groups = no permissions (enforces RBAC)
permissions: set[PermissionLevel] = set() permissions: set[PermissionLevel] = set()
@ -201,10 +203,7 @@ 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])
# Default to read-only if no recognized groups # No fallback - unrecognized groups get no permissions
if not permissions:
permissions.add(PermissionLevel.READ_ONLY)
return permissions return permissions

View File

@ -9,6 +9,7 @@ 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,
@ -68,6 +69,11 @@ 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)
@ -137,8 +143,9 @@ 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", file=sys.stderr) print("OAuth: disabled (single-user mode)", file=sys.stderr)
print("" * 40, file=sys.stderr) print("" * 40, file=sys.stderr)
# Create and run server # Create and run server