diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 0000000..a1a1263 --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,35 @@ +name: Go unit tests + +on: + pull_request: + push: + branches: + - main + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24.x' + - name: Check gofmt compliant + run: | + cd src + if [[ $(gofmt -l .) ]]; then + echo "Files not gofmt compliant:" + gofmt -l . + exit 1 + else + exit 0 + fi + - name: Install dependencies + run: | + cd src + go get . + - name: Test with Go + run: | + cd src + go test -v diff --git a/Dockerfile b/Dockerfile index ab5a260..79cc204 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24 +FROM golang:1.24 AS build-stage WORKDIR /app @@ -9,6 +9,12 @@ COPY src/*.go ./ RUN CGO_ENABLED=0 GOOS=linux go build -o /azimuth-authorization-webhook +FROM gcr.io/distroless/base-debian11 AS build-release-stage + +WORKDIR / + +COPY --from=build-stage /azimuth-authorization-webhook /azimuth-authorization-webhook + EXPOSE 8080 ENTRYPOINT ["/azimuth-authorization-webhook"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..585e916 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Azimuth Authorization Webhook + +A Kubernetes authorization webhook to protect sensitive namespaces when users require +read-write access to all other cluster resources (e.g when they wish to install arbitrary CRDS). + +Policy: +- Users cannot read secrets in protected namespaces by default +- Users cannot write any other resource in protected namespaces by default +- Internal K8s `system:` users may read/write to protected namespaces, excluding service accounts and `system:anonymous` +- Service accounts in protected namespaces may read/write to all protected namespaces +- Users specified as privileged may read/write to protected namespaces + +## Flags +| Flag | Arguments | +| --- | --- | +| `--allow-opinion-mode` | Specifies if the webhook should give its opinion on requests which it doesn't deny. If true, will set 'allowed' to `true` in SubjectAccessReview response. Default: `false` | +| `--additional-privileged-users` | Comma separate listed of users to be given read/write access to protected namespaces. Default: `""` | +| `--log-level` | Verbosity of logs
`0`: Internal errors only.
`1`: Logs high level requests info.
`2`: Logs HTTP dumps of requests.
Default: `1` | +| `--protected-namespaces` | Comma separated list of protected namespaces. Default: `kube-system,openstack-system` | diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 4df9bd4..dd363dd 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -23,7 +23,7 @@ spec: containerPort: 8080 protocol: TCP args: + - --additional-privileged-users={{ join "," .Values.additionalPrivilegedUsers }} - --log-level={{ .Values.logLevel }} - --protected-namespaces={{ join "," .Values.protectedNamespaces }} - - --unpriveleged-group={{ .Values.unprivilegedGroup }} - --allow-opinion-mode={{ .Values.allowOpinionMode }} diff --git a/chart/values.yaml b/chart/values.yaml index f6c873d..4676312 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -1,11 +1,11 @@ port: 8080 -version: 2f72ea1 +version: 706400d logLevel: 1 protectedNamespaces: - kube-system - openstack-system -unprivilegedGroup: "oidc:/platform-users" +additionalPrivilegedUsers: [] allowOpinionMode: false ingress: diff --git a/src/access_test.go b/src/access_test.go new file mode 100644 index 0000000..d56def4 --- /dev/null +++ b/src/access_test.go @@ -0,0 +1,474 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSystemUserAllowed(t *testing.T) { + accessTest(t, DefaultAuthorizer, false, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"system:kube-controller-manager", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestNamespaceServiceAccountAllowed(t *testing.T) { + accessTest(t, DefaultAuthorizer, false, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"system:serviceaccount:kube-system:good-service-account", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestNodeAccountAllowed(t *testing.T) { + accessTest(t, DefaultAuthorizer, false, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"system:node:my-node", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestBootstrapAccountAllowed(t *testing.T) { + accessTest(t, DefaultAuthorizer, false, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"system:bootstrap:my-bootstrap", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestCrossProtectedNamespaceServiceAccountAccessAllowed(t *testing.T) { + accessTest(t, DefaultAuthorizer, false, + []byte( + `{"kind":"SubjectAccessReview","apiVersion":"authorization.k8s.io/v1","metadata":{"creationTimestamp":null},"spec":{"resourceAttributes":{"namespace":"openstack-system","verb":"create","group":"apps","version":"v1","resource":"controllerrevisions"},"user":"system:serviceaccount:kube-system:daemon-set-controller","groups":["system:serviceaccounts","system:serviceaccounts:kube-system","system:authenticated"],"extra":{"authentication.kubernetes.io/credential-id":["JTI=3cf7d9de-5324-4df7-9447-47adb900f846"]},"uid":"cb35e0b5-1cfb-432f-acdf-5b5a0f924211"},"status":{"allowed":false}}`)) +} + +func TestWrongNamespaceServiceAccountDenied(t *testing.T) { + accessTest(t, DefaultAuthorizer, true, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"system:serviceaccount:othernamespace:bad-service-account", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestSystemAnonymousDenied(t *testing.T) { + accessTest(t, DefaultAuthorizer, true, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"system:anonymous", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestRequiredUserAllowed(t *testing.T) { + accessTest(t, DefaultAuthorizer, false, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"kubernetes-admin", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestAdditionalPrivilegedUserAllowed(t *testing.T) { + authorizer := CreateWebhookAuthorizer(DefaultProtectedNamespaces, []string{"special-user"}, false, 0) + accessTest(t, authorizer, false, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"special-user", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestUnprivilegedUserDenied(t *testing.T) { + accessTest(t, DefaultAuthorizer, true, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"kubernetes-not-admin", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestReadUnprotectedSecretsAllowed(t *testing.T) { + accessTest(t, DefaultAuthorizer, false, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"safe-namespace", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"kubernetes-not-admin", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestReadProtectedNonSecretsAllowed(t *testing.T) { + accessTest(t, DefaultAuthorizer, false, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"pods", + "name":"system-pod" + }, + "user":"kubernetes-not-admin", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestWriteProtectedNonSecretsDenied(t *testing.T) { + accessTest(t, DefaultAuthorizer, true, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"delete", + "version":"v1", + "resource":"pods", + "name":"system-pod" + }, + "user":"kubernetes-not-admin", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestWriteUnprotectedResourcesAllowed(t *testing.T) { + accessTest(t, DefaultAuthorizer, false, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"safe-namespace", + "verb":"delete", + "version":"v1", + "resource":"pods", + "name":"generic-pod" + }, + "user":"kubernetes-not-admin", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestEmptyNamespacesRequestsDenied(t *testing.T) { + accessTest(t, DefaultAuthorizer, true, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"not-admin", + "groups":["group1"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestProtectedReadAllRequestsDenied(t *testing.T) { + accessTest(t, DefaultAuthorizer, true, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"*", + "name":"important-creds" + }, + "user":"not-admin", + "groups":["group1"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestProtectedWriteAllRequestsDenied(t *testing.T) { + accessTest(t, DefaultAuthorizer, true, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"delete", + "version":"v1", + "resource":"*", + "name":"important-creds" + }, + "user":"not-admin", + "groups":["group1"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestProtectedAllVerbRequestsDenied(t *testing.T) { + accessTest(t, DefaultAuthorizer, true, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"*", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"not-admin", + "groups":["group1"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestProtectedAllVerbNonSecretRequestsDenied(t *testing.T) { + accessTest(t, DefaultAuthorizer, true, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"*", + "version":"v1", + "resource":"pods", + "name":"important-creds" + }, + "user":"not-admin", + "groups":["group1"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestAllowedTrueInRequestDenied(t *testing.T) { + accessTest(t, DefaultAuthorizer, true, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"system:anonymous", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":true + } + }`)) +} + +func accessTest(t *testing.T, authorizer func(w http.ResponseWriter, r *http.Request), expectDenied bool, jsonData []byte) { + data := bytes.NewBuffer(jsonData) + req := httptest.NewRequest(http.MethodPost, "/authorize", data) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + authorizer(resp, req) + + var sarResponse SubjectAccessReviewHTTPResponse + _ = json.NewDecoder(resp.Body).Decode(&sarResponse) + if sarResponse.Status.Denied != expectDenied { + var expectedResp string + if expectDenied { + expectedResp = "denied" + } else { + expectedResp = "allowed" + } + t.Errorf("Expected request to be %s\n", expectedResp) + } +} diff --git a/src/go.mod b/src/go.mod index fa5f8c7..830f5fe 100644 --- a/src/go.mod +++ b/src/go.mod @@ -2,7 +2,10 @@ module azimuth-cloud/azimuth-authorizaton-webhook go 1.24.3 -require k8s.io/api v0.33.1 +require ( + k8s.io/api v0.33.1 + k8s.io/apimachinery v0.33.1 +) require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect @@ -16,7 +19,6 @@ require ( golang.org/x/net v0.38.0 // indirect golang.org/x/text v0.23.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/apimachinery v0.33.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect diff --git a/src/input_test.go b/src/input_test.go new file mode 100644 index 0000000..12ac066 --- /dev/null +++ b/src/input_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" +) + +func TestInvalidJSON(t *testing.T) { + inputTest(t, DefaultAuthorizer, + []byte(`{bad json}`)) +} + +func TestInvalidResourceKind(t *testing.T) { + inputTest(t, DefaultAuthorizer, + []byte( + `{ + "kind":"NotASubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"kubernetes-admin", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestInvalidAPIVersion(t *testing.T) { + inputTest(t, DefaultAuthorizer, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"v0", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":"kubernetes-admin", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestEmptySpec(t *testing.T) { + inputTest(t, DefaultAuthorizer, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{}, + "status":{ + "allowed":false + } + }`)) +} + +func TestBadAttributesFields(t *testing.T) { + inputTest(t, DefaultAuthorizer, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":0, + "verb":0, + "version":0, + "resource":0, + "name":0 + }, + "user":"kubernetes-admin", + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func TestBadUser(t *testing.T) { + inputTest(t, DefaultAuthorizer, + []byte( + `{ + "kind":"SubjectAccessReview", + "apiVersion":"authorization.k8s.io/v1", + "spec":{ + "resourceAttributes":{ + "namespace":"kube-system", + "verb":"get", + "version":"v1", + "resource":"secrets", + "name":"important-creds" + }, + "user":0, + "groups":["system:authenticated"] + }, + "status":{ + "allowed":false + } + }`)) +} + +func inputTest(t *testing.T, authorizer func(w http.ResponseWriter, r *http.Request), jsonData []byte) { + data := bytes.NewBuffer(jsonData) + req := httptest.NewRequest(http.MethodPost, "/authorize", data) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + authorizer(resp, req) + + if resp.Code != http.StatusBadRequest { + t.Error("Expected 400 error") + } +} diff --git a/src/main.go b/src/main.go index 4caadfb..21af39b 100644 --- a/src/main.go +++ b/src/main.go @@ -3,12 +3,13 @@ package main import ( "encoding/json" "flag" - "fmt" authorizationv1 "k8s.io/api/authorization/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "log" "net/http" "net/http/httputil" "os" + "regexp" "slices" "strings" ) @@ -16,6 +17,7 @@ import ( // Creating mirror of authorizationv1.SubjectAccessReview struct but with modified Spec // to account for disparity in name of group key between Go ('Groups') and HTTP ('Group') API // causing issues with JSON unmarshalling +// Should not be written as HTTP response type SubjectAccessReviewAPI struct { metav1.TypeMeta metav1.ObjectMeta @@ -25,74 +27,155 @@ type SubjectAccessReviewAPI struct { Status authorizationv1.SubjectAccessReviewStatus } type SubjectAccessReviewSpecAPI struct { - ResourceAttributes *authorizationv1.ResourceAttributes + ResourceAttributes *authorizationv1.ResourceAttributes NonResourceAttributes *authorizationv1.NonResourceAttributes - User string - Group []string - Groups []string - Extra map[string]authorizationv1.ExtraValue - UID string + User string + Group []string + Groups []string + Extra map[string]authorizationv1.ExtraValue + UID string } +// Minimal SubjectAccessReview HTTP response type SubjectAccessReviewHTTPResponse struct { - ApiVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Status authorizationv1.SubjectAccessReviewStatus `json:"status"` + ApiVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Status authorizationv1.SubjectAccessReviewStatus `json:"status"` } var readonlyVerbs = []string{"get", "list", "watch", "proxy"} -func createAuthorizer(protectedNamespaces []string, unprivilegedGroup string,opinionMode bool,logLevel int) func(w http.ResponseWriter, r *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { +// Returns true if user is a service account with correct privileges or a privileged internal K8s system user +func isPrivilegedSystemUser(user string, protectedNamespaces []string) bool { + + requiredUsers := []string{"system:kube-controller-manager", "system:kube-scheduler", "kubernetes-admin", "kube-apiserver-kubelet-client"} + serviceAccountRegex, _ := regexp.Compile("system:serviceaccount:.+") + nodeAccountRegex, _ := regexp.Compile("system:node:.+") + bootstrapAccountRegex, _ := regexp.Compile("system:bootstrap:.+") + + if slices.Contains(requiredUsers, user) { + return true + } else if serviceAccountRegex.MatchString(user) { + // Allows service accounts if they originate from protected namespaces + serviceAccountNamespace := strings.Split(user, ":")[2] + return slices.Contains(protectedNamespaces, serviceAccountNamespace) + } else if nodeAccountRegex.MatchString(user) || bootstrapAccountRegex.MatchString(user) { + // All node and bootstrap accounts allowed + return true + } + + return false +} + +// Returns true if request passes webhook's resource access checks. If false, string with reason for rejection will also be returned, otherwise nil string +func isRequestAuthorized(sar SubjectAccessReviewAPI, protectedNamespaces []string, additionalPrivilegedUsers []string) (bool, string) { + isPrivilegedUser := slices.Contains(additionalPrivilegedUsers, sar.Spec.User) + isPrivilegedSystemUser := sar.Spec.ResourceAttributes != nil && isPrivilegedSystemUser(sar.Spec.User, protectedNamespaces) + isProtectedNamespace := sar.Spec.ResourceAttributes != nil && slices.Contains(protectedNamespaces, sar.Spec.ResourceAttributes.Namespace) + isSecret := sar.Spec.ResourceAttributes != nil && sar.Spec.ResourceAttributes.Resource == "secrets" + isReadonlyVerb := sar.Spec.ResourceAttributes != nil && slices.Contains(readonlyVerbs, sar.Spec.ResourceAttributes.Verb) + isAllNamespaceRequest := sar.Spec.ResourceAttributes != nil && sar.Spec.ResourceAttributes.Namespace == "" + isAllResourceRequest := sar.Spec.ResourceAttributes != nil && sar.Spec.ResourceAttributes.Resource == "*" + + var denyReason string + authorized := false + if isPrivilegedUser { + authorized = true + } else if isProtectedNamespace && !isPrivilegedSystemUser && isAllResourceRequest { + authorized = false + denyReason = "Cannot make * resource requests in protected namespace" + } else if (isAllNamespaceRequest || isProtectedNamespace) && !isPrivilegedSystemUser && isSecret { + authorized = false + denyReason = "Cannot access secrets in protected namespace" + } else if isProtectedNamespace && !isPrivilegedSystemUser && !isReadonlyVerb { + authorized = false + denyReason = "Cannot write to protected namespace" + } else { + authorized = true + } + return authorized, denyReason +} - if(logLevel >= 2) { fmt.Printf("Request received from %s\n:", r.RemoteAddr) } +func inputIsSanitised(sar SubjectAccessReviewAPI, httpWriter http.ResponseWriter) bool { + inputError := false + var errString string + if sar.APIVersion != "authorization.k8s.io/v1" { + errString = sar.APIVersion + " not supported. Currently support apiVersions: 'authorization.k8s.io/v1'" + inputError = true + } + // Most other issues will have been caught as JSON decoding errors + if sar.Kind != "SubjectAccessReview" || sar.Spec.User == "" { + errString = "Malformed SubjectAccessReview" + inputError = true + } + if inputError { + log.Println(errString) + http.Error(httpWriter, errString, http.StatusBadRequest) + return false + } else { + return true + } +} + +// Returns HTTP request handler to handle SubjectAccessReview API requests +func CreateWebhookAuthorizer(protectedNamespaces []string, additionalPrivilegedUsers []string, opinionMode bool, logLevel int) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { - // Print dump for logging dump, dumperr := httputil.DumpRequest(r, true) if dumperr != nil { - fmt.Println("Error dumping request:", dumperr) + log.Println("Error dumping request:", dumperr) return } - if(logLevel >= 2) { fmt.Println(string(dump)) } - var sar SubjectAccessReviewAPI err := json.NewDecoder(r.Body).Decode(&sar) if err != nil { - fmt.Println("[ERROR] ",err.Error()) - http.Error(w, "Invalid JSON", http.StatusBadRequest) + jsonErrString := "JSON decoding error: " + err.Error() + log.Println(jsonErrString) + http.Error(w, jsonErrString, http.StatusBadRequest) return } defer r.Body.Close() - isUnprivilegedUser := slices.Contains(sar.Spec.Groups, unprivilegedGroup) || slices.Contains(sar.Spec.Group, unprivilegedGroup) - isProtectedNamespace := sar.Spec.ResourceAttributes != nil && slices.Contains(protectedNamespaces, sar.Spec.ResourceAttributes.Namespace) // TODO: test if you can bypass with empty or all namespaces - isSecret := sar.Spec.ResourceAttributes != nil && sar.Spec.ResourceAttributes.Resource == "secrets" //TODO: test if you can bypass with * or singular nouns - isReadonlyVerb := sar.Spec.ResourceAttributes != nil && slices.Contains(readonlyVerbs, sar.Spec.ResourceAttributes.Verb) + if !inputIsSanitised(sar, w) { + return + } + + authorized, denyReason := isRequestAuthorized(sar, protectedNamespaces, additionalPrivilegedUsers) status := new(authorizationv1.SubjectAccessReviewStatus) - status.Allowed = false - if isUnprivilegedUser && isProtectedNamespace && isSecret { - status.Denied = true - status.Reason = "Cannot access secrets in protected namespace" - } else if isUnprivilegedUser && isProtectedNamespace && !isReadonlyVerb { - status.Denied = true - status.Reason = "Cannot write to protected namespace" - } else { - status.Denied = false - status.Allowed = opinionMode - if(!opinionMode){ status.Reason = "Webhook doesn't give opinion, delegated to other authorizers" } + status.Denied = !authorized + status.Allowed = opinionMode && authorized + + if status.Denied { + status.Reason = denyReason + } else if !opinionMode { + status.Reason = "Webhook doesn't give opinion, delegated to other authorizers" } - //todo: add status.EvaluationError handling responseReview := new(SubjectAccessReviewHTTPResponse) responseReview.ApiVersion = "authorization.k8s.io/v1" responseReview.Kind = "SubjectAccessReview" responseReview.Status = *status - if(status.Denied && logLevel == 1) { fmt.Println(string(dump)) } - if(status.Denied && logLevel >= 1){ fmt.Printf("[DENIED] Reason: %s\n",status.Reason) } + var deniedLogOutput string + if status.Denied { + deniedLogOutput = "Denied" + } else { + deniedLogOutput = "Allowed" + } + + // TODO: find way to map cluster IPs from X-Forward headers to clusters + if logLevel >= 1 && sar.Spec.NonResourceAttributes != nil { + log.Println("[Cluster: " + r.Header.Get("X-Forwarded-For") + "] " + deniedLogOutput + " non-resource request from " + sar.Spec.User + ". Reason: " + status.Reason) + } + if logLevel >= 1 && sar.Spec.ResourceAttributes != nil { + log.Println("[Cluster: " + r.Header.Get("X-Forwarded-For") + "] " + deniedLogOutput + " request from " + sar.Spec.User + " to " + sar.Spec.ResourceAttributes.Verb + " " + sar.Spec.ResourceAttributes.Resource + " in namespace " + sar.Spec.ResourceAttributes.Namespace + ". Reason: " + status.Reason) + } + if logLevel >= 2 { + log.Printf("HTTP Dump: \n%s\n", string(dump)) + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(responseReview) @@ -100,19 +183,20 @@ func createAuthorizer(protectedNamespaces []string, unprivilegedGroup string,opi } func main() { - var unprivelegedGroup = flag.String("unpriveleged-group", "oidc:/platform-users", "Name of group which should have their permissions restricted for protected namespaces") + var additionalPrivilegedUsersCSL = flag.String("additional-privileged-users", "", "Comma separated list of users that should be allowed to write to protected namespaces, excluding 'system:*' users") var protectedNamespacesCSL = flag.String("protected-namespaces", "kube-system,openstack-system", "Comma separated list of namespaces which unprivileged users will have limited permissions for") var logLevel = flag.Int("log-level", 1, "Verbosity of logs. Values: [0-2]") - var opinionMode = flag.Bool("allow-opinion-mode",false,"Specifies if this webhook should give its opinion on requests which it doesn't deny. If true, will set 'allowed' to true in SubjectAccessReview.") + var opinionMode = flag.Bool("allow-opinion-mode", false, "Specifies if this webhook should give its opinion on requests which it doesn't deny. If true, will set 'allowed' to true in SubjectAccessReview.") flag.Parse() protectedNamespaces := strings.Split(*protectedNamespacesCSL, ",") + additionalPrivilegedUsers := strings.Split(*additionalPrivilegedUsersCSL, ",") - http.HandleFunc("/authorize", createAuthorizer(protectedNamespaces, *unprivelegedGroup, *opinionMode, *logLevel)) - fmt.Printf("Server started\n") + http.HandleFunc("/authorize", CreateWebhookAuthorizer(protectedNamespaces, additionalPrivilegedUsers, *opinionMode, *logLevel)) + log.Printf("Server started\n") err := http.ListenAndServe(":8080", nil) if err != nil { - fmt.Printf("error starting server: %s\n", err) + log.Printf("error starting server: %s\n", err) os.Exit(1) } } diff --git a/src/test_utils.go b/src/test_utils.go new file mode 100644 index 0000000..c7eccf4 --- /dev/null +++ b/src/test_utils.go @@ -0,0 +1,10 @@ +package main + +import ( + "net/http" +) + +var DefaultProtectedNamespaces = []string{"kube-system", "openstack-system"} +var DefaultAdditionalPrivilegedUsers = []string{} + +var DefaultAuthorizer func(w http.ResponseWriter, r *http.Request) = CreateWebhookAuthorizer(DefaultProtectedNamespaces, DefaultAdditionalPrivilegedUsers, false, 0)