Skip to content

Commit 2ddfe4c

Browse files
authored
Merge pull request #50 from Azure/guwe/access-level
Feat: support flexible access-level
2 parents 53ed66f + b07e661 commit 2ddfe4c

File tree

9 files changed

+271
-65
lines changed

9 files changed

+271
-65
lines changed

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,37 @@ Command line arguments:
9696

9797
```sh
9898
Usage of ./mcp-kubernetes:
99+
--access-level string Access level (readonly, readwrite, or admin) (default "readonly")
99100
--additional-tools string Comma-separated list of additional tools to support (kubectl is always enabled). Available: helm,cilium
100101
--allow-namespaces string Comma-separated list of namespaces to allow (empty means all allowed)
101102
--host string Host to listen for the server (only used with transport sse or streamable-http) (default "127.0.0.1")
102103
--port int Port to listen for the server (only used with transport sse or streamable-http) (default 8000)
103-
--readonly Enable read-only mode (prevents write operations)
104104
--timeout int Timeout for command execution in seconds, default is 60s (default 60)
105105
--transport string Transport mechanism to use (stdio, sse or streamable-http) (default "stdio")
106106
```
107107
108+
### Access Levels
109+
110+
The `--access-level` flag controls what operations are allowed:
111+
112+
- **`readonly`** (default): Only read operations are allowed (get, describe, logs, etc.)
113+
- **`readwrite`**: Read and write operations are allowed (create, delete, apply, etc.)
114+
- **`admin`**: All operations are allowed, including admin operations (cordon, drain, taint, etc.)
115+
116+
Example configurations:
117+
118+
```json
119+
// Admin access
120+
{
121+
"mcpServers": {
122+
"kubernetes": {
123+
"command": "mcp-kubernetes",
124+
"args": ["--access-level", "admin"]
125+
}
126+
}
127+
}
128+
```
129+
108130
## Usage
109131
110132
Ask any questions about Kubernetes cluster in your AI client, e.g.

cmd/mcp-kubernetes/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import (
1212
func main() {
1313
// Create configuration instance and parse command line arguments
1414
cfg := config.NewConfig()
15-
cfg.ParseFlags()
15+
if err := cfg.ParseFlags(); err != nil {
16+
fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err)
17+
os.Exit(1)
18+
}
1619

1720
// Create validator and run validation checks
1821
v := config.NewValidator(cfg)

example/clients/autogen/main.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,15 @@ async def get_tools(
8383
mcp_kubernetes_bin: str,
8484
additional_tools: str,
8585
allow_namespaces: str,
86-
readonly: bool,
86+
access_level: str,
8787
) -> list:
8888
"""Initialize and return tools for the MCP Kubernetes server."""
8989
server_params = StdioServerParams(
9090
command=mcp_kubernetes_bin,
9191
args=[
9292
f"--additional-tools={additional_tools}",
9393
f"--allow-namespaces={allow_namespaces}",
94-
"--readonly" if readonly else "",
94+
f"--access-level={access_level}",
9595
],
9696
)
9797

@@ -133,12 +133,12 @@ async def main() -> None:
133133

134134
additional_tools = app_config.get("additional_tools", "")
135135
allow_namespaces = app_config.get("allow_namespaces", "")
136-
readonly = app_config.get("readonly", False)
136+
access_level = app_config.get("access_level", "readonly")
137137

138138
az_model_client = get_az_model_client(model_config)
139139

140140
tools = await get_tools(
141-
mcp_kubernetes_bin, additional_tools, allow_namespaces, readonly
141+
mcp_kubernetes_bin, additional_tools, allow_namespaces, access_level
142142
)
143143

144144
agent = AssistantAgent(

pkg/config/config.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"fmt"
45
"strings"
56

67
"github.com/Azure/mcp-kubernetes/pkg/security"
@@ -20,7 +21,7 @@ type ConfigData struct {
2021
Transport string
2122
Host string
2223
Port int
23-
ReadOnly bool
24+
AccessLevel string
2425
AllowNamespaces string
2526
}
2627

@@ -32,13 +33,13 @@ func NewConfig() *ConfigData {
3233
SecurityConfig: security.NewSecurityConfig(),
3334
Transport: "stdio",
3435
Port: 8000,
35-
ReadOnly: false,
36+
AccessLevel: "readonly",
3637
AllowNamespaces: "",
3738
}
3839
}
3940

4041
// ParseFlags parses command line arguments and updates the configuration
41-
func (cfg *ConfigData) ParseFlags() {
42+
func (cfg *ConfigData) ParseFlags() error {
4243
// Server configuration
4344
flag.StringVar(&cfg.Transport, "transport", "stdio", "Transport mechanism to use (stdio, sse or streamable-http)")
4445
flag.StringVar(&cfg.Host, "host", "127.0.0.1", "Host to listen for the server (only used with transport sse or streamable-http)")
@@ -50,14 +51,24 @@ func (cfg *ConfigData) ParseFlags() {
5051
"Comma-separated list of additional tools to support (kubectl is always enabled). Available: helm,cilium")
5152

5253
// Security settings
53-
flag.BoolVar(&cfg.ReadOnly, "readonly", false, "Enable read-only mode (prevents write operations)")
54+
flag.StringVar(&cfg.AccessLevel, "access-level", "readonly", "Access level (readonly, readwrite, or admin)")
5455
flag.StringVar(&cfg.AllowNamespaces, "allow-namespaces", "",
5556
"Comma-separated list of namespaces to allow (empty means all allowed)")
5657

5758
flag.Parse()
5859

59-
// Update security config
60-
cfg.SecurityConfig.ReadOnly = cfg.ReadOnly
60+
// Update security config with access level
61+
switch cfg.AccessLevel {
62+
case "readonly":
63+
cfg.SecurityConfig.AccessLevel = security.AccessLevelReadOnly
64+
case "readwrite":
65+
cfg.SecurityConfig.AccessLevel = security.AccessLevelReadWrite
66+
case "admin":
67+
cfg.SecurityConfig.AccessLevel = security.AccessLevelAdmin
68+
default:
69+
return fmt.Errorf("invalid access level '%s'. Valid values are: readonly, readwrite, admin", cfg.AccessLevel)
70+
}
71+
6172
if cfg.AllowNamespaces != "" {
6273
cfg.SecurityConfig.SetAllowedNamespaces(cfg.AllowNamespaces)
6374
}
@@ -72,6 +83,8 @@ func (cfg *ConfigData) ParseFlags() {
7283
cfg.AdditionalTools[tool] = true
7384
}
7485
}
86+
87+
return nil
7588
}
7689

7790
var availableTools = []string{"kubectl", "helm", "cilium"}

pkg/config/config_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestAccessLevelValidation(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
accessLevel string
11+
expectError bool
12+
}{
13+
{"Valid readonly", "readonly", false},
14+
{"Valid readwrite", "readwrite", false},
15+
{"Valid admin", "admin", false},
16+
{"Invalid value", "invalid", true},
17+
{"Empty value", "", true},
18+
{"Case sensitive", "READONLY", true},
19+
{"Case sensitive", "ReadOnly", true},
20+
}
21+
22+
for _, tt := range tests {
23+
t.Run(tt.name, func(t *testing.T) {
24+
cfg := NewConfig()
25+
cfg.AccessLevel = tt.accessLevel
26+
27+
// Skip flag parsing, just test the validation logic
28+
var err error
29+
switch cfg.AccessLevel {
30+
case "readonly", "readwrite", "admin":
31+
err = nil
32+
default:
33+
err = &ValidationError{Message: "invalid access level"}
34+
}
35+
36+
if tt.expectError && err == nil {
37+
t.Errorf("Expected error for access level '%s', but got none", tt.accessLevel)
38+
} else if !tt.expectError && err != nil {
39+
t.Errorf("Did not expect error for access level '%s', but got: %v", tt.accessLevel, err)
40+
}
41+
})
42+
}
43+
}
44+
45+
// ValidationError for testing purposes
46+
type ValidationError struct {
47+
Message string
48+
}
49+
50+
func (e *ValidationError) Error() string {
51+
return e.Message
52+
}

pkg/security/security.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@ import (
55
"strings"
66
)
77

8+
// AccessLevel defines the level of access allowed
9+
type AccessLevel string
10+
11+
const (
12+
AccessLevelReadOnly AccessLevel = "readonly"
13+
AccessLevelReadWrite AccessLevel = "readwrite"
14+
AccessLevelAdmin AccessLevel = "admin"
15+
)
16+
817
// SecurityConfig holds security-related configuration
918
type SecurityConfig struct {
10-
// ReadOnly mode prevents write operations
11-
ReadOnly bool
19+
// AccessLevel defines the level of access allowed (readonly, readwrite, admin)
20+
AccessLevel AccessLevel
1221
// AllowedNamespaces is a list of literal namespace names
1322
allowedNamespaces []string
1423
// allowedNamespacesRe is a list of compiled regex patterns for namespace matching
@@ -18,7 +27,7 @@ type SecurityConfig struct {
1827
// NewSecurityConfig creates a new SecurityConfig instance
1928
func NewSecurityConfig() *SecurityConfig {
2029
return &SecurityConfig{
21-
ReadOnly: false,
30+
AccessLevel: AccessLevelReadOnly,
2231
allowedNamespaces: []string{},
2332
allowedNamespacesRe: []*regexp.Regexp{},
2433
}

pkg/security/validator.go

Lines changed: 94 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,18 @@ var (
1717
KubectlReadOperations = []string{
1818
"get", "describe", "explain", "logs", "top", "auth", "config",
1919
"cluster-info", "api-resources", "api-versions", "version", "diff",
20-
"completion", "help", "kustomize", "options", "plugin", "proxy", "wait", "cp",
20+
"completion", "help", "kustomize", "options", "plugin", "proxy", "wait", "events",
21+
}
22+
23+
// KubectlReadWriteOperations defines kubectl operations that modify state but are not admin operations
24+
KubectlReadWriteOperations = []string{
25+
"create", "delete", "apply", "expose", "run", "set", "rollout", "scale",
26+
"autoscale", "label", "annotate", "patch", "replace", "cp", "exec",
27+
}
28+
29+
// KubectlAdminOperations defines kubectl operations that require admin privileges
30+
KubectlAdminOperations = []string{
31+
"cordon", "uncordon", "drain", "taint", "certificate",
2132
}
2233

2334
// HelmReadOperations defines helm operations that don't modify state
@@ -69,12 +80,46 @@ func (v *Validator) getReadOperationsList(commandType string) []string {
6980
}
7081
}
7182

83+
// getReadWriteOperationsList returns the appropriate list of read-write operations based on command type
84+
func (v *Validator) getReadWriteOperationsList(commandType string) []string {
85+
switch commandType {
86+
case CommandTypeKubectl:
87+
return KubectlReadWriteOperations
88+
case CommandTypeHelm:
89+
// For now, assume helm write operations are same as read operations
90+
// This can be expanded when helm write operations are defined
91+
return []string{}
92+
case CommandTypeCilium:
93+
// For now, assume cilium write operations are same as read operations
94+
// This can be expanded when cilium write operations are defined
95+
return []string{}
96+
default:
97+
return []string{}
98+
}
99+
}
100+
101+
// getAdminOperationsList returns the appropriate list of admin operations based on command type
102+
func (v *Validator) getAdminOperationsList(commandType string) []string {
103+
switch commandType {
104+
case CommandTypeKubectl:
105+
return KubectlAdminOperations
106+
case CommandTypeHelm:
107+
// For now, assume helm admin operations are not defined
108+
// This can be expanded when helm admin operations are defined
109+
return []string{}
110+
case CommandTypeCilium:
111+
// For now, assume cilium admin operations are not defined
112+
// This can be expanded when cilium admin operations are defined
113+
return []string{}
114+
default:
115+
return []string{}
116+
}
117+
}
118+
72119
// ValidateCommand validates a command against all security settings
73120
func (v *Validator) ValidateCommand(command, commandType string) error {
74-
readOperations := v.getReadOperationsList(commandType)
75-
76-
// Check readonly restrictions
77-
if err := v.validateReadOnly(command, readOperations); err != nil {
121+
// Check access level restrictions
122+
if err := v.validateAccessLevel(command, commandType); err != nil {
78123
return err
79124
}
80125

@@ -86,11 +131,36 @@ func (v *Validator) ValidateCommand(command, commandType string) error {
86131
return nil
87132
}
88133

89-
// validateReadOnly validates if a command is allowed in read-only mode
90-
func (v *Validator) validateReadOnly(command string, readOperations []string) error {
91-
// Check if we're in readonly mode and if this is a write operation
92-
if v.secConfig.ReadOnly && !v.isReadOperation(command, readOperations) {
93-
return &ValidationError{Message: "Error: Cannot execute write operations in read-only mode"}
134+
// validateAccessLevel validates if a command is allowed based on the configured access level
135+
func (v *Validator) validateAccessLevel(command, commandType string) error {
136+
readOperations := v.getReadOperationsList(commandType)
137+
readWriteOperations := v.getReadWriteOperationsList(commandType)
138+
adminOperations := v.getAdminOperationsList(commandType)
139+
140+
operation := v.extractOperationFromCommand(command, commandType)
141+
142+
switch v.secConfig.AccessLevel {
143+
case AccessLevelReadOnly:
144+
if !v.isOperationInList(operation, readOperations) {
145+
return &ValidationError{Message: "Error: Cannot execute write or admin operations in read-only mode"}
146+
}
147+
case AccessLevelReadWrite:
148+
if !v.isOperationInList(operation, readOperations) && !v.isOperationInList(operation, readWriteOperations) {
149+
// Check if it's an admin operation to provide better error message
150+
if v.isOperationInList(operation, adminOperations) {
151+
return &ValidationError{Message: "Error: Cannot execute admin operations in read-write mode"}
152+
}
153+
return &ValidationError{Message: "Error: Operation not allowed in read-write mode"}
154+
}
155+
case AccessLevelAdmin:
156+
// Admin level allows all operations (read, write, and admin)
157+
if !v.isOperationInList(operation, readOperations) &&
158+
!v.isOperationInList(operation, readWriteOperations) &&
159+
!v.isOperationInList(operation, adminOperations) {
160+
return &ValidationError{Message: "Error: Unknown operation"}
161+
}
162+
default:
163+
return &ValidationError{Message: "Error: Invalid access level configuration"}
94164
}
95165

96166
return nil
@@ -118,28 +188,32 @@ func (v *Validator) validateNamespaceScope(command string) error {
118188
return nil
119189
}
120190

121-
// isReadOperation checks if a command is a read operation
122-
func (v *Validator) isReadOperation(command string, allowedOperations []string) bool {
191+
// isOperationInList checks if an operation is in the given list
192+
func (v *Validator) isOperationInList(operation string, allowedOperations []string) bool {
193+
for _, allowed := range allowedOperations {
194+
if operation == allowed {
195+
return true
196+
}
197+
}
198+
return false
199+
}
200+
201+
// extractOperationFromCommand extracts the operation from a command
202+
func (v *Validator) extractOperationFromCommand(command, commandType string) string {
123203
cmdParts := strings.Fields(command)
124204
var operation string
125205

126206
for _, part := range cmdParts {
127207
if !strings.HasPrefix(part, "-") {
128208
// Skip the initial command name (kubectl, helm, cilium)
129-
if part != CommandTypeKubectl && part != CommandTypeHelm && part != CommandTypeCilium {
209+
if part != commandType {
130210
operation = part
131211
break
132212
}
133213
}
134214
}
135215

136-
for _, allowed := range allowedOperations {
137-
if operation == allowed {
138-
return true
139-
}
140-
}
141-
142-
return false
216+
return operation
143217
}
144218

145219
// extractNamespaceFromCommand extracts the namespace from a command

0 commit comments

Comments
 (0)