Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/kubespiffe/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 5 additions & 10 deletions deployment/workload-registration/example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions deployment/workload/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ spec:
app: workload
kubespiffe/enabled: "true"
spec:
serviceAccountName: default
serviceAccountName: workload-sa
containers:
- name: workload
image: workload:latest
Expand Down Expand Up @@ -48,7 +48,7 @@ spec:
app: another-workload
kubespiffe/enabled: "true"
spec:
serviceAccountName: default
serviceAccountName: another-workload-sa
containers:
- name: workload
image: workload:latest
Expand Down
11 changes: 11 additions & 0 deletions deployment/workload/serviceaccounts.yaml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 11 additions & 2 deletions pkg/apis/kubespiffe/v1alpha1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
17 changes: 17 additions & 0 deletions pkg/apis/kubespiffe/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 33 additions & 16 deletions pkg/k8s/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
121 changes: 121 additions & 0 deletions pkg/k8s/helpers_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
})
}
}