diff --git a/Justfile b/Justfile index 6421684..d184d71 100644 --- a/Justfile +++ b/Justfile @@ -25,13 +25,14 @@ deploy: docker kubectl apply -f ./deployment/kubespiffed/deployment.yaml --context kind-kubespiffe kubectl apply -f ./deployment/kubespiffed/service.yaml --context kind-kubespiffe kubectl apply -f ./deployment/kubespiffed/rbac.yaml --context kind-kubespiffe - kubectl apply -f ./deployment/workload-registration/crd.yaml --context kind-kubespiffe + kubectl apply -f ./deployment/workload-registration/crd.yaml --context kind-kubespiffe + kubectl apply -f ./deployment/workload-registration/example.yaml --context kind-kubespiffe + + kubectl apply -f ./deployment/workload/serviceaccounts.yaml --context kind-kubespiffe kubectl apply -f ./deployment/workload/deployment.yaml --context kind-kubespiffe kubectl apply -f ./deployment/workload/service.yaml --context kind-kubespiffe kubectl apply -f ./deployment/workload/unattested-deployment.yaml --context kind-kubespiffe - - kubectl apply -f ./deployment/workload-registration/example.yaml --context kind-kubespiffe kubectl rollout restart deployment -n kubespiffe kubespiffed kubectl rollout restart deployment workload diff --git a/cmd/kubespiffe/main.go b/cmd/kubespiffe/main.go index d48dafc..e4fb41f 100644 --- a/cmd/kubespiffe/main.go +++ b/cmd/kubespiffe/main.go @@ -52,7 +52,7 @@ func main() { return } - wr, err := k8s.AttestPod(ctx, cs, kscs, claims["kubernetes.io"].(map[string]any)) + wr, err := k8s.AttestPod(ctx, cs, kscs, claims) if err != nil || wr == nil { slog.Info("❌ Pod rejected", "error", err) return diff --git a/deployment/workload-registration/example.yaml b/deployment/workload-registration/example.yaml index de459c6..1e84f02 100644 --- a/deployment/workload-registration/example.yaml +++ b/deployment/workload-registration/example.yaml @@ -2,25 +2,20 @@ apiVersion: kubespiffe.io/v1alpha1 kind: WorkloadRegistration metadata: name: workload - namespace: default spec: - spiffeID: spiffe://example.org/ns/default/sa/default + spiffeID: spiffe://example.org/ns/default/sa/workload-sa svidType: X509 selector: namespace: default - serviceAccountName: default - podName: workload + serviceAccountName: workload-sa --- apiVersion: kubespiffe.io/v1alpha1 kind: WorkloadRegistration metadata: - name: another - namespace: default + name: another-workload spec: - spiffeID: spiffe://example.org/ns/default/sa/default + spiffeID: spiffe://example.org/app/another-workload svidType: X509 selector: namespace: default - serviceAccountName: default - podName: another - + serviceAccountName: another-workload-sa diff --git a/deployment/workload/deployment.yaml b/deployment/workload/deployment.yaml index d0de564..09f816b 100644 --- a/deployment/workload/deployment.yaml +++ b/deployment/workload/deployment.yaml @@ -14,7 +14,7 @@ spec: app: workload kubespiffe/enabled: "true" spec: - serviceAccountName: default + serviceAccountName: workload-sa containers: - name: workload image: workload:latest @@ -48,7 +48,7 @@ spec: app: another-workload kubespiffe/enabled: "true" spec: - serviceAccountName: default + serviceAccountName: another-workload-sa containers: - name: workload image: workload:latest diff --git a/deployment/workload/serviceaccounts.yaml b/deployment/workload/serviceaccounts.yaml new file mode 100644 index 0000000..e28f34a --- /dev/null +++ b/deployment/workload/serviceaccounts.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: workload-sa + namespace: default +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: another-workload-sa + namespace: default diff --git a/pkg/apis/kubespiffe/v1alpha1/types.go b/pkg/apis/kubespiffe/v1alpha1/types.go index 8994db0..17e363a 100644 --- a/pkg/apis/kubespiffe/v1alpha1/types.go +++ b/pkg/apis/kubespiffe/v1alpha1/types.go @@ -18,8 +18,17 @@ type WorkloadRegistration struct { } type WorkloadRegistrationSpec struct { - SPIFFEID string `json:"spiffeID"` - SVIDType string `json:"svidType"` + SPIFFEID string `json:"spiffeID"` + SVIDType string `json:"svidType"` + Selector WorkloadRegistrationSelector `json:"selector,omitempty"` +} + +// WorkloadRegistrationSelector constrains which Pods recieve an SVID from kubespiffe +// Each field is optional; an empty field matches any value +type WorkloadRegistrationSelector struct { + Namespace string `json:"namespace,omitempty"` + ServiceAccountName string `json:"serviceAccountName,omitempty"` + PodName string `json:"podName,omitempty"` } type WorkloadRegistrationStatus struct{} diff --git a/pkg/apis/kubespiffe/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/kubespiffe/v1alpha1/zz_generated.deepcopy.go index 91248b3..efcd4ad 100644 --- a/pkg/apis/kubespiffe/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/kubespiffe/v1alpha1/zz_generated.deepcopy.go @@ -74,9 +74,26 @@ func (in *WorkloadRegistrationList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkloadRegistrationSelector) DeepCopyInto(out *WorkloadRegistrationSelector) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadRegistrationSelector. +func (in *WorkloadRegistrationSelector) DeepCopy() *WorkloadRegistrationSelector { + if in == nil { + return nil + } + out := new(WorkloadRegistrationSelector) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkloadRegistrationSpec) DeepCopyInto(out *WorkloadRegistrationSpec) { *out = *in + out.Selector = in.Selector return } diff --git a/pkg/k8s/helpers.go b/pkg/k8s/helpers.go index 44a80f4..bf9d4bc 100644 --- a/pkg/k8s/helpers.go +++ b/pkg/k8s/helpers.go @@ -17,7 +17,6 @@ import ( "github.com/jsnctl/kubespiffe/pkg/apis/kubespiffe/v1alpha1" "github.com/jsnctl/kubespiffe/pkg/generated/clientset/versioned" "github.com/lestrrat-go/jwx/jwk" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" @@ -203,7 +202,7 @@ type KubernetesWorkloadClaims struct { Namespace string `json:"namespace"` Node KubernetesResource `json:"node"` Pod KubernetesResource `json:"pod"` - ServiceAccount KubernetesResource `json:"serviceAccount"` + ServiceAccount KubernetesResource `json:"serviceaccount"` } type KubernetesResource struct { @@ -214,30 +213,48 @@ type KubernetesResource struct { func AttestPod( ctx context.Context, cs *kubernetes.Clientset, - kscs *versioned.Clientset, + kscs versioned.Interface, claims map[string]any, ) (*v1alpha1.WorkloadRegistration, error) { - b, err := json.Marshal(claims) + k8sClaims, ok := claims["kubernetes.io"] + if !ok { + return nil, fmt.Errorf("missing kubernetes.io claims in token") + } + b, err := json.Marshal(k8sClaims) if err != nil { - return nil, fmt.Errorf("marshal: %w", err) + return nil, fmt.Errorf("marshal kubernetes.io claims: %w", err) } var c KubernetesWorkloadClaims if err := json.Unmarshal(b, &c); err != nil { - return nil, err + return nil, fmt.Errorf("unmarshal kubernetes.io claims: %w", err) + } + + list, err := kscs.KubespiffeV1alpha1().WorkloadRegistrations("").List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing WorkloadRegistrations: %w", err) } - // Quick hacky prune of workload pod name in PSAT claim to test allow/deny policy - podName := strings.Split(c.Pod.Name, "-")[0] - return kscs.KubespiffeV1alpha1().WorkloadRegistrations("").Get(ctx, podName, metav1.GetOptions{}) + for i := range list.Items { + wreg := &list.Items[i] + if selectorMatches(wreg.Spec.Selector, c) { + return wreg, nil + } + } + + return nil, fmt.Errorf("no WorkloadRegistration matches pod %s/%s (sa: %s)", + c.Namespace, c.Pod.Name, c.ServiceAccount.Name) } -func checkForLabel(pod *corev1.Pod, key, value string) error { - val, ok := pod.GetLabels()[key] - if !ok { - return fmt.Errorf("pod label does not exist") +// selectorMatches returns true if all non-empty WorkloadRegistrationSelector fields match the claims +func selectorMatches(sel v1alpha1.WorkloadRegistrationSelector, c KubernetesWorkloadClaims) bool { + if sel.Namespace != "" && sel.Namespace != c.Namespace { + return false + } + if sel.ServiceAccountName != "" && sel.ServiceAccountName != c.ServiceAccount.Name { + return false } - if val != value { - return fmt.Errorf("pod value does not match expected") + if sel.PodName != "" && sel.PodName != c.Pod.Name { + return false } - return nil + return true } diff --git a/pkg/k8s/helpers_test.go b/pkg/k8s/helpers_test.go index 23c5567..e536333 100644 --- a/pkg/k8s/helpers_test.go +++ b/pkg/k8s/helpers_test.go @@ -1,13 +1,19 @@ package k8s import ( + "context" "crypto/rand" "crypto/rsa" "encoding/base64" "math/big" "testing" + "github.com/jsnctl/kubespiffe/pkg/apis/kubespiffe/v1alpha1" + kubespiffefake "github.com/jsnctl/kubespiffe/pkg/generated/clientset/versioned/fake" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" ) func Test_extractBearer(t *testing.T) { @@ -118,3 +124,118 @@ func Test_jwkToPublicKey(t *testing.T) { }) } } + +func makeClaims(namespace, podName, saName string) map[string]any { + return map[string]any{ + "iss": "https://kubernetes.default.svc.cluster.local", + "kubernetes.io": map[string]any{ + "namespace": namespace, + "pod": map[string]any{ + "name": podName, + "uid": "abc-123", + }, + "serviceaccount": map[string]any{ + "name": saName, + "uid": "def-456", + }, + }, + } +} + +func wreg(name, namespace, saName, podName, spiffeID string) *v1alpha1.WorkloadRegistration { + return &v1alpha1.WorkloadRegistration{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: v1alpha1.WorkloadRegistrationSpec{ + SPIFFEID: spiffeID, + SVIDType: "X509", + Selector: v1alpha1.WorkloadRegistrationSelector{ + Namespace: namespace, + ServiceAccountName: saName, + PodName: podName, + }, + }, + } +} + +func TestAttestPod(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + registrations []*v1alpha1.WorkloadRegistration + claims map[string]any + wantSPIFFEID string + wantErr bool + }{ + { + name: "exact match on all selector fields", + registrations: []*v1alpha1.WorkloadRegistration{ + wreg("web", "default", "web-sa", "web-pod-abc", "spiffe://example.org/web"), + }, + claims: makeClaims("default", "web-pod-abc", "web-sa"), + wantSPIFFEID: "spiffe://example.org/web", + }, + { + name: "matches first of multiple registrations", + registrations: []*v1alpha1.WorkloadRegistration{ + wreg("other", "default", "other-sa", "other-pod", "spiffe://example.org/other"), + wreg("web", "default", "web-sa", "web-pod-abc", "spiffe://example.org/web"), + }, + claims: makeClaims("default", "web-pod-abc", "web-sa"), + wantSPIFFEID: "spiffe://example.org/web", + }, + { + name: "wildcard pod name matches any pod in namespace", + registrations: []*v1alpha1.WorkloadRegistration{ + wreg("batch", "jobs", "batch-sa", "", "spiffe://example.org/batch"), + }, + claims: makeClaims("jobs", "batch-worker-7f9d2", "batch-sa"), + wantSPIFFEID: "spiffe://example.org/batch", + }, + { + name: "namespace mismatch — no match", + registrations: []*v1alpha1.WorkloadRegistration{ + wreg("web", "production", "web-sa", "web-pod", "spiffe://example.org/web"), + }, + claims: makeClaims("staging", "web-pod", "web-sa"), + wantErr: true, + }, + { + name: "service account mismatch — no match", + registrations: []*v1alpha1.WorkloadRegistration{ + wreg("web", "default", "restricted-sa", "web-pod", "spiffe://example.org/web"), + }, + claims: makeClaims("default", "web-pod", "other-sa"), + wantErr: true, + }, + { + name: "no registrations — no match", + registrations: nil, + claims: makeClaims("default", "web-pod", "web-sa"), + wantErr: true, + }, + { + name: "missing kubernetes.io claims", + claims: map[string]any{"iss": "https://kubernetes.default.svc.cluster.local"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runtimeObjects := make([]runtime.Object, len(tt.registrations)) + for i, r := range tt.registrations { + runtimeObjects[i] = r + } + kscs := kubespiffefake.NewSimpleClientset(runtimeObjects...) + + got, err := AttestPod(ctx, nil, kscs, tt.claims) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantSPIFFEID, got.Spec.SPIFFEID) + }) + } +}