diff --git a/go.mod b/go.mod index 01a3f94..9ca7f45 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.5 require ( github.com/google/jsonschema-go v0.4.2 github.com/mcpchecker/mcpchecker v0.0.3 + k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 ) @@ -13,13 +14,30 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/text v0.2.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp/event v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/exp/jsonrpc2 v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/net v0.49.0 // indirect @@ -29,6 +47,8 @@ require ( golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.9.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect diff --git a/go.sum b/go.sum index 2fb4a68..1feeb70 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,6 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -24,6 +27,8 @@ github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ98 github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= @@ -36,6 +41,12 @@ github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv4 github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -43,10 +54,16 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mcpchecker/mcpchecker v0.0.3 h1:/6V+6a1vvkjjoebmP9V2gl3sh2phnaUbMb9hQnt5rsU= github.com/mcpchecker/mcpchecker v0.0.3/go.mod h1:15Hh69Ou6hXPW/Y4anmZs1goa/EdU+QBKGJvhsrcfXE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -57,9 +74,15 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -78,10 +101,14 @@ golang.org/x/exp/event v0.0.0-20260112195511-716be5621a96 h1:l+bY+u9cx/1NImWfu0O golang.org/x/exp/event v0.0.0-20260112195511-716be5621a96/go.mod h1:Mdr2zZUK+6kOEaz94oXdRj8dk4gD0X6uJ5tlEy7hG04= golang.org/x/exp/jsonrpc2 v0.0.0-20260112195511-716be5621a96 h1:cN9X2vSBmT3Ruw2UlbJNLJh0iBqTmtSB0dRfh5aumiY= golang.org/x/exp/jsonrpc2 v0.0.0-20260112195511-716be5621a96/go.mod h1:FGo2nTjljKiUliOH66kiDGWw1Hl9sPoyb/bFuIxjzeQ= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= @@ -90,12 +117,15 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= diff --git a/pkg/extension/authcani.go b/pkg/extension/authcani.go new file mode 100644 index 0000000..71beb0b --- /dev/null +++ b/pkg/extension/authcani.go @@ -0,0 +1,99 @@ +package extension + +import ( + "context" + "fmt" + + "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" +) + +func (e *Extension) handleAuthCanI(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + if e.client == nil { + return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil + } + + args, ok := req.Args.(map[string]any) + if !ok { + return sdk.Failure(fmt.Errorf("args must be an object")), nil + } + + verb, _ := args["verb"].(string) + resource, _ := args["resource"].(string) + as, _ := args["as"].(string) + namespace, _ := args["namespace"].(string) + apiGroup, _ := args["apiGroup"].(string) + resourceName, _ := args["resourceName"].(string) + + if verb == "" { + return sdk.Failure(fmt.Errorf("verb is required")), nil + } + if resource == "" { + return sdk.Failure(fmt.Errorf("resource is required")), nil + } + if as == "" { + return sdk.Failure(fmt.Errorf("as is required")), nil + } + + e.LogInfo(ctx, "Checking permissions", map[string]any{ + "verb": verb, + "resource": resource, + "as": as, + "namespace": namespace, + "apiGroup": apiGroup, + "resourceName": resourceName, + }) + + allowed, reason, err := e.client.CheckAccess(ctx, as, verb, resource, apiGroup, namespace, resourceName) + if err != nil { + e.LogError(ctx, "Failed to check permissions", map[string]any{ + "error": err.Error(), + }) + return sdk.Failure(fmt.Errorf("failed to check permissions: %w", err)), nil + } + + e.LogInfo(ctx, "Permission check completed", map[string]any{ + "allowed": allowed, + "reason": reason, + }) + + // Handle expect.allowed verification + if expectArg, hasExpect := args["expect"]; hasExpect { + expect, ok := expectArg.(map[string]any) + if !ok { + return sdk.Failure(fmt.Errorf("expect must be an object")), nil + } + + if expectedAllowed, hasAllowed := expect["allowed"]; hasAllowed { + expectedBool, ok := expectedAllowed.(bool) + if !ok { + return sdk.Failure(fmt.Errorf("expect.allowed must be a boolean")), nil + } + + if allowed != expectedBool { + return sdk.FailureWithMessage( + fmt.Sprintf("permission check failed: expected allowed=%v but got allowed=%v", expectedBool, allowed), + fmt.Errorf("permission expectation not met"), + ), nil + } + } + } + + msg := fmt.Sprintf("%s can %s %s", as, verb, resource) + if namespace != "" { + msg += fmt.Sprintf(" in namespace %s", namespace) + } else { + msg += " cluster-wide" + } + + if allowed { + return sdk.SuccessWithOutputs(msg+": allowed", map[string]string{ + "allowed": "true", + "reason": reason, + }), nil + } + + return sdk.SuccessWithOutputs(msg+": denied", map[string]string{ + "allowed": "false", + "reason": reason, + }), nil +} diff --git a/pkg/extension/authcani_test.go b/pkg/extension/authcani_test.go new file mode 100644 index 0000000..e2635a4 --- /dev/null +++ b/pkg/extension/authcani_test.go @@ -0,0 +1,237 @@ +package extension + +import ( + "context" + "fmt" + "testing" + + "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" +) + +func TestHandleAuthCanI(t *testing.T) { + tests := []struct { + name string + args any + client *mockClient + wantSuccess bool + }{ + { + name: "allowed action", + args: map[string]any{ + "verb": "get", + "resource": "pods", + "as": "system:serviceaccount:default:test-sa", + "namespace": "default", + }, + client: &mockClient{ + checkAccessFn: func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + return true, "allowed by RBAC", nil + }, + }, + wantSuccess: true, + }, + { + name: "denied action", + args: map[string]any{ + "verb": "delete", + "resource": "pods", + "as": "system:serviceaccount:default:test-sa", + "namespace": "default", + }, + client: &mockClient{ + checkAccessFn: func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + return false, "denied by RBAC", nil + }, + }, + wantSuccess: true, + }, + { + name: "cluster-wide check allowed", + args: map[string]any{ + "verb": "list", + "resource": "namespaces", + "as": "admin-user", + }, + client: &mockClient{ + checkAccessFn: func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + if namespace != "" { + return false, "", fmt.Errorf("expected cluster-wide check") + } + return true, "", nil + }, + }, + wantSuccess: true, + }, + { + name: "expect allowed matches", + args: map[string]any{ + "verb": "get", + "resource": "pods", + "as": "alice", + "namespace": "test-ns", + "expect": map[string]any{ + "allowed": true, + }, + }, + client: &mockClient{ + checkAccessFn: func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + return true, "", nil + }, + }, + wantSuccess: true, + }, + { + name: "expect allowed mismatch", + args: map[string]any{ + "verb": "delete", + "resource": "pods", + "as": "alice", + "namespace": "test-ns", + "expect": map[string]any{ + "allowed": true, + }, + }, + client: &mockClient{ + checkAccessFn: func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + return false, "denied", nil + }, + }, + wantSuccess: false, + }, + { + name: "expect denied matches", + args: map[string]any{ + "verb": "delete", + "resource": "pods", + "as": "readonly-user", + "namespace": "prod", + "expect": map[string]any{ + "allowed": false, + }, + }, + client: &mockClient{ + checkAccessFn: func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + return false, "denied by policy", nil + }, + }, + wantSuccess: true, + }, + { + name: "missing verb", + args: map[string]any{ + "resource": "pods", + "as": "alice", + }, + client: &mockClient{}, + wantSuccess: false, + }, + { + name: "missing resource", + args: map[string]any{ + "verb": "get", + "as": "alice", + }, + client: &mockClient{}, + wantSuccess: false, + }, + { + name: "missing as", + args: map[string]any{ + "verb": "get", + "resource": "pods", + }, + client: &mockClient{}, + wantSuccess: false, + }, + { + name: "with api group", + args: map[string]any{ + "verb": "create", + "resource": "deployments", + "as": "developer", + "namespace": "apps", + "apiGroup": "apps", + }, + client: &mockClient{ + checkAccessFn: func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + if apiGroup != "apps" { + return false, "", fmt.Errorf("expected apiGroup=apps, got %s", apiGroup) + } + return true, "", nil + }, + }, + wantSuccess: true, + }, + { + name: "with specific resource name", + args: map[string]any{ + "verb": "get", + "resource": "secrets", + "as": "limited-user", + "namespace": "default", + "resourceName": "my-secret", + }, + client: &mockClient{ + checkAccessFn: func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + if resourceName != "my-secret" { + return false, "", fmt.Errorf("expected resourceName=my-secret, got %s", resourceName) + } + return true, "", nil + }, + }, + wantSuccess: true, + }, + { + name: "service account format", + args: map[string]any{ + "verb": "get", + "resource": "pods", + "as": "system:serviceaccount:create-simple-rbac:reader-sa", + "namespace": "create-simple-rbac", + }, + client: &mockClient{ + checkAccessFn: func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + if user != "system:serviceaccount:create-simple-rbac:reader-sa" { + return false, "", fmt.Errorf("expected full service account name") + } + return true, "", nil + }, + }, + wantSuccess: true, + }, + { + name: "client error", + args: map[string]any{ + "verb": "get", + "resource": "pods", + "as": "alice", + "namespace": "default", + }, + client: &mockClient{ + checkAccessFn: func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + return false, "", fmt.Errorf("connection refused") + }, + }, + wantSuccess: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := &Extension{ + Extension: sdk.NewExtension(sdk.ExtensionInfo{Name: "test"}), + client: tt.client, + } + + req := &sdk.OperationRequest{Args: tt.args} + result, err := ext.handleAuthCanI(context.Background(), req) + + if err != nil { + t.Fatalf("handleAuthCanI() returned error: %v", err) + } + if result.Success != tt.wantSuccess { + t.Errorf("handleAuthCanI() success = %v, want %v, message = %s", result.Success, tt.wantSuccess, result.Message) + } + }) + } +} diff --git a/pkg/extension/client.go b/pkg/extension/client.go index b6f482d..1f99f9e 100644 --- a/pkg/extension/client.go +++ b/pkg/extension/client.go @@ -3,10 +3,12 @@ package extension import ( "context" + authorizationv1 "k8s.io/api/authorization/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" + authorizationv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" ) // ResourceClient abstracts Kubernetes resource operations for testability. @@ -20,11 +22,15 @@ type ResourceClient interface { // Delete removes a Kubernetes resource. Delete(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error + + // CheckAccess checks if a user can perform an action on a resource. + CheckAccess(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) } // dynamicClientAdapter adapts the Kubernetes dynamic client to the ResourceClient interface. type dynamicClientAdapter struct { - client dynamic.Interface + client dynamic.Interface + authzClient authorizationv1client.AuthorizationV1Interface } func (a *dynamicClientAdapter) Create(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) { @@ -47,3 +53,25 @@ func (a *dynamicClientAdapter) Delete(ctx context.Context, gvr schema.GroupVersi } return a.client.Resource(gvr).Delete(ctx, name, opts) } + +func (a *dynamicClientAdapter) CheckAccess(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + sar := &authorizationv1.SubjectAccessReview{ + Spec: authorizationv1.SubjectAccessReviewSpec{ + User: user, + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Verb: verb, + Resource: resource, + Group: apiGroup, + Namespace: namespace, + Name: resourceName, + }, + }, + } + + result, err := a.authzClient.SubjectAccessReviews().Create(ctx, sar, metav1.CreateOptions{}) + if err != nil { + return false, "", err + } + + return result.Status.Allowed, result.Status.Reason, nil +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 59fd236..8d088d6 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -9,6 +9,7 @@ import ( "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" "k8s.io/client-go/dynamic" + authorizationv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" "k8s.io/client-go/tools/clientcmd" ) @@ -74,7 +75,12 @@ func (e *Extension) handleInitialize(config map[string]any) error { return fmt.Errorf("failed to create dynamic client: %w", err) } - e.client = &dynamicClientAdapter{client: client} + authzClient, err := authorizationv1client.NewForConfig(kubeconfig) + if err != nil { + return fmt.Errorf("failed to create authorization client: %w", err) + } + + e.client = &dynamicClientAdapter{client: client, authzClient: authzClient} return nil } diff --git a/pkg/extension/mock_client_test.go b/pkg/extension/mock_client_test.go index d302469..0c41377 100644 --- a/pkg/extension/mock_client_test.go +++ b/pkg/extension/mock_client_test.go @@ -9,9 +9,10 @@ import ( ) type mockClient struct { - createFn func(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) - getFn func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (*unstructured.Unstructured, error) - deleteFn func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error + createFn func(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) + getFn func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (*unstructured.Unstructured, error) + deleteFn func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error + checkAccessFn func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) } func (m *mockClient) Create(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) { @@ -34,3 +35,10 @@ func (m *mockClient) Delete(ctx context.Context, gvr schema.GroupVersionResource } return nil } + +func (m *mockClient) CheckAccess(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) { + if m.checkAccessFn != nil { + return m.checkAccessFn(ctx, user, verb, resource, apiGroup, namespace, resourceName) + } + return true, "", nil +} diff --git a/pkg/extension/operations.go b/pkg/extension/operations.go index 92fdd28..f2f14d0 100644 --- a/pkg/extension/operations.go +++ b/pkg/extension/operations.go @@ -105,4 +105,52 @@ func (e *Extension) registerOperations() { ), e.handleDelete, ) + + e.AddOperation( + sdk.NewOperation("authCanI", + sdk.WithDescription("Check if a user or service account can perform an action on a resource"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "Permission check parameters", + Properties: map[string]*jsonschema.Schema{ + "verb": { + Type: "string", + Description: "Action verb (get, list, create, delete, watch, patch, update, etc.)", + }, + "resource": { + Type: "string", + Description: "Resource name (pods, deployments, configmaps, etc.)", + }, + "as": { + Type: "string", + Description: "User or service account to impersonate (e.g., alice, system:serviceaccount:ns:sa-name)", + }, + "namespace": { + Type: "string", + Description: "Namespace scope (optional, empty for cluster-wide check)", + }, + "apiGroup": { + Type: "string", + Description: "API group (optional, empty for core API, e.g., apps, batch, rbac.authorization.k8s.io)", + }, + "resourceName": { + Type: "string", + Description: "Specific resource name to check access for (optional)", + }, + "expect": { + Type: "object", + Description: "Expected result for inline verification", + Properties: map[string]*jsonschema.Schema{ + "allowed": { + Type: "boolean", + Description: "Expected permission result (true for allowed, false for denied)", + }, + }, + }, + }, + Required: []string{"verb", "resource", "as"}, + }), + ), + e.handleAuthCanI, + ) }