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)