PodSecurity: admission: admission library

Co-authored-by: Jordan Liggitt <liggitt@google.com>
This commit is contained in:
Tim Allclair 2021-06-22 14:09:05 -04:00 committed by Jordan Liggitt
parent 29f5ebf1fe
commit 02a6187757
5 changed files with 1143 additions and 0 deletions

View File

@ -0,0 +1,549 @@
/*
Copyright 2021 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package admission
import (
"context"
"fmt"
"net/http"
"reflect"
"time"
"k8s.io/klog/v2"
admissionv1 "k8s.io/api/admission/v1"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
admissionapi "k8s.io/pod-security-admission/admission/api"
"k8s.io/pod-security-admission/admission/api/validation"
"k8s.io/pod-security-admission/api"
"k8s.io/pod-security-admission/metrics"
"k8s.io/pod-security-admission/policy"
)
const (
namespaceMaxPodsToCheck = 3000
namespacePodCheckTimeout = 1 * time.Second
)
// Admission implements the core admission logic for the Pod Security Admission controller.
// The admission logic can be
type Admission struct {
Configuration *admissionapi.PodSecurityConfiguration
// Getting policy checks per level/version
Evaluator policy.Evaluator
// Metrics
Metrics metrics.EvaluationRecorder
// Arbitrary object --> PodSpec
PodSpecExtractor PodSpecExtractor
// API connections
NamespaceGetter NamespaceGetter
PodLister PodLister
defaultPolicy api.Policy
}
type NamespaceGetter interface {
GetNamespace(ctx context.Context, name string) (*corev1.Namespace, error)
}
type PodLister interface {
ListPods(ctx context.Context, namespace string) ([]*corev1.Pod, error)
}
// PodSpecExtractor extracts a PodSpec from pod-controller resources that embed a PodSpec.
// This interface can be extended to enforce policy on CRDs for custom pod-controllers.
type PodSpecExtractor interface {
// HasPodSpec returns true if the given resource type MAY contain an extractable PodSpec.
HasPodSpec(schema.GroupResource) bool
// ExtractPodSpec returns a pod spec and metadata to evaluate from the object.
// An error returned here does not block admission of the pod-spec-containing object and is not returned to the user.
// If the object has no pod spec, return `nil, nil, nil`.
ExtractPodSpec(runtime.Object) (*metav1.ObjectMeta, *corev1.PodSpec, error)
}
var defaultPodSpecResources = map[schema.GroupResource]bool{
corev1.Resource("pods"): true,
corev1.Resource("replicationcontrollers"): true,
corev1.Resource("podtemplates"): true,
appsv1.Resource("replicasets"): true,
appsv1.Resource("deployments"): true,
appsv1.Resource("statefulsets"): true,
appsv1.Resource("daemonsets"): true,
batchv1.Resource("jobs"): true,
batchv1.Resource("cronjobs"): true,
}
type DefaultPodSpecExtractor struct{}
func (DefaultPodSpecExtractor) HasPodSpec(gr schema.GroupResource) bool {
return defaultPodSpecResources[gr]
}
func (DefaultPodSpecExtractor) ExtractPodSpec(obj runtime.Object) (*metav1.ObjectMeta, *corev1.PodSpec, error) {
switch o := obj.(type) {
case *corev1.Pod:
return &o.ObjectMeta, &o.Spec, nil
case *corev1.PodTemplate:
return extractPodSpecFromTemplate(&o.Template)
case *corev1.ReplicationController:
return extractPodSpecFromTemplate(o.Spec.Template)
case *appsv1.ReplicaSet:
return extractPodSpecFromTemplate(&o.Spec.Template)
case *appsv1.Deployment:
return extractPodSpecFromTemplate(&o.Spec.Template)
case *appsv1.DaemonSet:
return extractPodSpecFromTemplate(&o.Spec.Template)
case *appsv1.StatefulSet:
return extractPodSpecFromTemplate(&o.Spec.Template)
case *batchv1.Job:
return extractPodSpecFromTemplate(&o.Spec.Template)
case *batchv1.CronJob:
return extractPodSpecFromTemplate(&o.Spec.JobTemplate.Spec.Template)
default:
return nil, nil, fmt.Errorf("unexpected object type: %s", obj.GetObjectKind().GroupVersionKind().String())
}
}
func (DefaultPodSpecExtractor) PodSpecResources() []schema.GroupResource {
retval := make([]schema.GroupResource, 0, len(defaultPodSpecResources))
for r := range defaultPodSpecResources {
retval = append(retval, r)
}
return retval
}
func extractPodSpecFromTemplate(template *corev1.PodTemplateSpec) (*metav1.ObjectMeta, *corev1.PodSpec, error) {
if template == nil {
return nil, nil, nil
}
return &template.ObjectMeta, &template.Spec, nil
}
// CompleteConfiguration() sets up default or derived configuration.
func (a *Admission) CompleteConfiguration() error {
if a.Configuration != nil {
if p, err := admissionapi.ToPolicy(a.Configuration.Defaults); err != nil {
return err
} else {
a.defaultPolicy = p
}
}
if a.PodSpecExtractor == nil {
a.PodSpecExtractor = &DefaultPodSpecExtractor{}
}
return nil
}
// ValidateConfiguration() ensures all required fields are set with valid values.
func (a *Admission) ValidateConfiguration() error {
if a.Configuration == nil {
return fmt.Errorf("configuration required")
} else if errs := validation.ValidatePodSecurityConfiguration(a.Configuration); len(errs) > 0 {
return errs.ToAggregate()
} else {
if p, err := admissionapi.ToPolicy(a.Configuration.Defaults); err != nil {
return err
} else if !reflect.DeepEqual(p, a.defaultPolicy) {
return fmt.Errorf("default policy does not match; CompleteConfiguration() was not called before ValidateConfiguration()")
}
}
// TODO: check metrics is non-nil?
if a.PodSpecExtractor == nil {
return fmt.Errorf("PodSpecExtractor required")
}
if a.Evaluator == nil {
return fmt.Errorf("Evaluator required")
}
if a.NamespaceGetter == nil {
return fmt.Errorf("NamespaceGetter required")
}
if a.PodLister == nil {
return fmt.Errorf("PodLister required")
}
return nil
}
// Validate admits an API request.
// The objects in admission attributes are expected to be external v1 objects that we care about.
func (a *Admission) Validate(ctx context.Context, attrs admission.Attributes) admissionv1.AdmissionResponse {
var response admissionv1.AdmissionResponse
switch attrs.GetResource().GroupResource() {
case corev1.Resource("namespaces"):
response = a.ValidateNamespace(ctx, attrs)
case corev1.Resource("pods"):
response = a.ValidatePod(ctx, attrs)
default:
response = a.ValidatePodController(ctx, attrs)
}
// TODO: record metrics.
return response
}
func (a *Admission) ValidateNamespace(ctx context.Context, attrs admission.Attributes) admissionv1.AdmissionResponse {
// short-circuit on subresources
if attrs.GetSubresource() != "" {
return allowedResponse()
}
namespace, ok := attrs.GetObject().(*corev1.Namespace)
if !ok {
klog.InfoS("failed to assert namespace type", "type", reflect.TypeOf(attrs.GetObject()))
return internalErrorResponse("failed to decode namespace")
}
newPolicy, newErr := a.PolicyToEvaluate(namespace.Labels)
switch attrs.GetOperation() {
case admission.Create:
// require valid labels on create
if newErr != nil {
return invalidResponse(newErr.Error())
}
return allowedResponse()
case admission.Update:
// if update, check if policy labels changed
oldNamespace, ok := attrs.GetOldObject().(*corev1.Namespace)
if !ok {
klog.InfoS("failed to assert old namespace type", "type", reflect.TypeOf(attrs.GetOldObject()))
return internalErrorResponse("failed to decode old namespace")
}
oldPolicy, oldErr := a.PolicyToEvaluate(oldNamespace.Labels)
// require valid labels on update if they have changed
if newErr != nil && (oldErr == nil || newErr.Error() != oldErr.Error()) {
return invalidResponse(newErr.Error())
}
// Skip dry-running pods:
// * if the enforce policy is unchanged
// * if the new enforce policy is privileged
// * if the new enforce is the same version and level was relaxed
// * for exempt namespaces
if newPolicy.Enforce == oldPolicy.Enforce {
return allowedResponse()
}
if newPolicy.Enforce.Level == api.LevelPrivileged {
return allowedResponse()
}
if newPolicy.Enforce.Version == oldPolicy.Enforce.Version &&
api.CompareLevels(newPolicy.Enforce.Level, oldPolicy.Enforce.Level) < 1 {
return allowedResponse()
}
if a.exemptNamespace(attrs.GetNamespace()) {
return allowedResponse()
}
response := allowedResponse()
response.Warnings = a.EvaluatePodsInNamespace(ctx, namespace.Name, newPolicy.Enforce)
return response
default:
return allowedResponse()
}
}
// ignoredPodSubresources is a set of ignored Pod subresources.
// Any other subresource is expected to be a *v1.Pod type and is evaluated.
// This ensures a version skewed webhook fails safe and denies an unknown pod subresource that allows modifying the pod spec.
var ignoredPodSubresources = map[string]bool{
"exec": true,
"attach": true,
"binding": true,
"eviction": true,
"log": true,
"portforward": true,
"proxy": true,
"status": true,
}
func (a *Admission) ValidatePod(ctx context.Context, attrs admission.Attributes) admissionv1.AdmissionResponse {
// short-circuit on ignored subresources
if ignoredPodSubresources[attrs.GetSubresource()] {
return allowedResponse()
}
// short-circuit on exempt namespaces and users
if a.exemptNamespace(attrs.GetNamespace()) || a.exemptUser(attrs.GetUserInfo().GetName()) {
return allowedResponse()
}
pod, ok := attrs.GetObject().(*corev1.Pod)
if !ok {
klog.InfoS("failed to assert pod type", "type", reflect.TypeOf(attrs.GetObject()))
return internalErrorResponse("failed to decode pod")
}
enforce := true
if attrs.GetOperation() == admission.Update {
oldPod, ok := attrs.GetOldObject().(*corev1.Pod)
if !ok {
klog.InfoS("failed to assert old pod type", "type", reflect.TypeOf(attrs.GetOldObject()))
return internalErrorResponse("failed to decode old pod")
}
if !isSignificantPodUpdate(pod, oldPod) {
// Nothing we care about changed, so always allow the update.
return allowedResponse()
}
}
return a.EvaluatePod(ctx, attrs.GetNamespace(), &pod.ObjectMeta, &pod.Spec, enforce)
}
func (a *Admission) ValidatePodController(ctx context.Context, attrs admission.Attributes) admissionv1.AdmissionResponse {
// short-circuit on subresources
if attrs.GetSubresource() != "" {
return allowedResponse()
}
// short-circuit on exempt namespaces and users
if a.exemptNamespace(attrs.GetNamespace()) || a.exemptUser(attrs.GetUserInfo().GetName()) {
return allowedResponse()
}
podMetadata, podSpec, err := a.PodSpecExtractor.ExtractPodSpec(attrs.GetObject())
if err != nil {
klog.ErrorS(err, "failed to extract pod spec")
return internalErrorResponse("failed to extract pod template")
}
if podMetadata == nil && podSpec == nil {
// if a controller with an optional pod spec does not contain a pod spec, skip validation
return allowedResponse()
}
return a.EvaluatePod(ctx, attrs.GetNamespace(), podMetadata, podSpec, false)
}
// EvaluatePod looks up the policy for the pods namespace, and checks it against the given pod(-like) object.
// The enforce policy is only checked if enforce=true.
func (a *Admission) EvaluatePod(ctx context.Context, namespaceName string, podMetadata *metav1.ObjectMeta, podSpec *corev1.PodSpec, enforce bool) admissionv1.AdmissionResponse {
// short-circuit on exempt runtimeclass
if a.exemptRuntimeClass(podSpec.RuntimeClassName) {
return allowedResponse()
}
namespace, err := a.NamespaceGetter.GetNamespace(ctx, namespaceName)
if err != nil {
klog.ErrorS(err, "failed to fetch pod namespace", "namespace", namespaceName)
return internalErrorResponse(fmt.Sprintf("failed to lookup namespace %s", namespaceName))
}
auditAnnotations := map[string]string{}
nsPolicy, err := a.PolicyToEvaluate(namespace.Labels)
if err != nil {
klog.V(2).InfoS("failed to parse PodSecurity namespace labels", "err", err)
auditAnnotations["error"] = fmt.Sprintf("Failed to parse policy: %v", err)
}
response := allowedResponse()
if enforce {
if result := policy.AggregateCheckResults(a.Evaluator.EvaluatePod(nsPolicy.Enforce, podMetadata, podSpec)); !result.Allowed {
response = forbiddenResponse(result.ForbiddenDetail())
}
}
// TODO: reuse previous evaluation if audit level+version is the same as enforce level+version
if result := policy.AggregateCheckResults(a.Evaluator.EvaluatePod(nsPolicy.Audit, podMetadata, podSpec)); !result.Allowed {
auditAnnotations["audit"] = result.ForbiddenDetail()
}
// TODO: reuse previous evaluation if warn level+version is the same as audit or enforce level+version
if result := policy.AggregateCheckResults(a.Evaluator.EvaluatePod(nsPolicy.Warn, podMetadata, podSpec)); !result.Allowed {
// TODO: Craft a better user-facing warning message
response.Warnings = append(response.Warnings, fmt.Sprintf("Pod violates PodSecurity profile %s: %s", nsPolicy.Warn.String(), result.ForbiddenDetail()))
}
response.AuditAnnotations = auditAnnotations
return response
}
func (a *Admission) EvaluatePodsInNamespace(ctx context.Context, namespace string, enforce api.LevelVersion) []string {
timeout := namespacePodCheckTimeout
if deadline, ok := ctx.Deadline(); ok {
timeRemaining := time.Duration(0.9 * float64(time.Until(deadline))) // Leave a little time to respond.
if timeout > timeRemaining {
timeout = timeRemaining
}
}
deadline := time.Now().Add(timeout)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
pods, err := a.PodLister.ListPods(ctx, namespace)
if err != nil {
klog.ErrorS(err, "Failed to list pods", "namespace", namespace)
return []string{"Failed to list pods"}
}
var warnings []string
if len(pods) > namespaceMaxPodsToCheck {
warnings = append(warnings, fmt.Sprintf("Large namespace: only checking the first %d of %d pods", namespaceMaxPodsToCheck, len(pods)))
pods = pods[0:namespaceMaxPodsToCheck]
}
for i, pod := range pods {
// short-circuit on exempt runtimeclass
if a.exemptRuntimeClass(pod.Spec.RuntimeClassName) {
continue
}
r := policy.AggregateCheckResults(a.Evaluator.EvaluatePod(enforce, &pod.ObjectMeta, &pod.Spec))
if !r.Allowed {
// TODO: consider aggregating results (e.g. multiple pods failed for the same reasons)
warnings = append(warnings, fmt.Sprintf("%s: %s", pod.Name, r.ForbiddenReason()))
}
if time.Now().After(deadline) {
return append(warnings, fmt.Sprintf("Timeout reached after checking %d pods", i+1))
}
}
return warnings
}
func (a *Admission) PolicyToEvaluate(labels map[string]string) (api.Policy, error) {
return api.PolicyToEvaluate(labels, a.defaultPolicy)
}
// allowResponse is the response used when the admission decision is allow.
func allowedResponse() admissionv1.AdmissionResponse {
return admissionv1.AdmissionResponse{Allowed: true}
}
// forbiddenResponse is the response used when the admission decision is deny for policy violations.
func forbiddenResponse(msg string) admissionv1.AdmissionResponse {
return admissionv1.AdmissionResponse{
Allowed: false,
Result: &metav1.Status{
Status: metav1.StatusFailure,
Reason: metav1.StatusReasonForbidden,
Message: msg,
Code: http.StatusForbidden,
},
}
}
// invalidResponse is the response used for namespace requests when namespace labels are invalid.
func invalidResponse(msg string) admissionv1.AdmissionResponse {
return admissionv1.AdmissionResponse{
Allowed: false,
Result: &metav1.Status{
Status: metav1.StatusFailure,
Reason: metav1.StatusReasonInvalid,
Message: msg,
Code: 422,
},
}
}
// internalErrorResponse is the response used for unexpected errors
func internalErrorResponse(msg string) admissionv1.AdmissionResponse {
return admissionv1.AdmissionResponse{
Allowed: false,
Result: &metav1.Status{
Status: metav1.StatusFailure,
Reason: metav1.StatusReasonInternalError,
Message: msg,
Code: http.StatusInternalServerError,
},
}
}
// isSignificantPodUpdate determines whether a pod update should trigger a policy evaluation.
// Relevant mutable pod fields as of 1.21 are image and seccomp annotations:
// * https://github.com/kubernetes/kubernetes/blob/release-1.21/pkg/apis/core/validation/validation.go#L3947-L3949
func isSignificantPodUpdate(pod, oldPod *corev1.Pod) bool {
if pod.Annotations[corev1.SeccompPodAnnotationKey] != oldPod.Annotations[corev1.SeccompPodAnnotationKey] {
return true
}
if len(pod.Spec.Containers) != len(oldPod.Spec.Containers) {
return true
}
if len(pod.Spec.InitContainers) != len(oldPod.Spec.InitContainers) {
return true
}
for i := 0; i < len(pod.Spec.Containers); i++ {
if isSignificantContainerUpdate(&pod.Spec.Containers[i], &oldPod.Spec.Containers[i], pod.Annotations, oldPod.Annotations) {
return true
}
}
for i := 0; i < len(pod.Spec.InitContainers); i++ {
if isSignificantContainerUpdate(&pod.Spec.InitContainers[i], &oldPod.Spec.InitContainers[i], pod.Annotations, oldPod.Annotations) {
return true
}
}
for _, c := range pod.Spec.EphemeralContainers {
var oldC *corev1.Container
for i, oc := range oldPod.Spec.EphemeralContainers {
if oc.Name == c.Name {
oldC = (*corev1.Container)(&oldPod.Spec.EphemeralContainers[i].EphemeralContainerCommon)
break
}
}
if oldC == nil {
return true // EphemeralContainer added
}
if isSignificantContainerUpdate((*corev1.Container)(&c.EphemeralContainerCommon), oldC, pod.Annotations, oldPod.Annotations) {
return true
}
}
return false
}
// isSignificantContainerUpdate determines whether a container update should trigger a policy evaluation.
func isSignificantContainerUpdate(container, oldContainer *corev1.Container, annotations, oldAnnotations map[string]string) bool {
if container.Image != oldContainer.Image {
return true
}
seccompKey := corev1.SeccompContainerAnnotationKeyPrefix + container.Name
return annotations[seccompKey] != oldAnnotations[seccompKey]
}
func (a *Admission) exemptNamespace(namespace string) bool {
if len(namespace) == 0 {
return false
}
// TODO: consider optimizing to O(1) lookup
return containsString(namespace, a.Configuration.Exemptions.Namespaces)
}
func (a *Admission) exemptUser(username string) bool {
if len(username) == 0 {
return false
}
// TODO: consider optimizing to O(1) lookup
return containsString(username, a.Configuration.Exemptions.Usernames)
}
func (a *Admission) exemptRuntimeClass(runtimeClass *string) bool {
if runtimeClass == nil || len(*runtimeClass) == 0 {
return false
}
// TODO: consider optimizing to O(1) lookup
return containsString(*runtimeClass, a.Configuration.Exemptions.RuntimeClasses)
}
func containsString(needle string, haystack []string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}

View File

@ -0,0 +1,465 @@
/*
Copyright 2021 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package admission
import (
"context"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
admissionv1 "k8s.io/api/admission/v1"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
admissionapi "k8s.io/pod-security-admission/admission/api"
"k8s.io/pod-security-admission/api"
"k8s.io/pod-security-admission/policy"
"k8s.io/utils/pointer"
)
func TestDefaultExtractPodSpec(t *testing.T) {
metadata := metav1.ObjectMeta{
Name: "foo-pod",
}
spec := corev1.PodSpec{
Containers: []corev1.Container{{
Name: "foo-container",
}},
}
objects := []runtime.Object{
&corev1.Pod{
ObjectMeta: metadata,
Spec: spec,
},
&corev1.PodTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "foo-template"},
Template: corev1.PodTemplateSpec{
ObjectMeta: metadata,
Spec: spec,
},
},
&corev1.ReplicationController{
ObjectMeta: metav1.ObjectMeta{Name: "foo-rc"},
Spec: corev1.ReplicationControllerSpec{
Template: &corev1.PodTemplateSpec{
ObjectMeta: metadata,
Spec: spec,
},
},
},
&appsv1.ReplicaSet{
ObjectMeta: metav1.ObjectMeta{Name: "foo-rs"},
Spec: appsv1.ReplicaSetSpec{
Template: corev1.PodTemplateSpec{
ObjectMeta: metadata,
Spec: spec,
},
},
},
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "foo-deployment"},
Spec: appsv1.DeploymentSpec{
Template: corev1.PodTemplateSpec{
ObjectMeta: metadata,
Spec: spec,
},
},
},
&appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{Name: "foo-ss"},
Spec: appsv1.StatefulSetSpec{
Template: corev1.PodTemplateSpec{
ObjectMeta: metadata,
Spec: spec,
},
},
},
&appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{Name: "foo-ds"},
Spec: appsv1.DaemonSetSpec{
Template: corev1.PodTemplateSpec{
ObjectMeta: metadata,
Spec: spec,
},
},
},
&batchv1.Job{
ObjectMeta: metav1.ObjectMeta{Name: "foo-job"},
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
ObjectMeta: metadata,
Spec: spec,
},
},
},
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{Name: "foo-cronjob"},
Spec: batchv1.CronJobSpec{
JobTemplate: batchv1.JobTemplateSpec{
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
ObjectMeta: metadata,
Spec: spec,
},
},
},
},
},
}
extractor := &DefaultPodSpecExtractor{}
for _, obj := range objects {
name := obj.(metav1.Object).GetName()
actualMetadata, actualSpec, err := extractor.ExtractPodSpec(obj)
assert.NoError(t, err, name)
assert.Equal(t, &metadata, actualMetadata, "%s: Metadata mismatch", name)
assert.Equal(t, &spec, actualSpec, "%s: PodSpec mismatch", name)
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "foo-svc",
},
}
_, _, err := extractor.ExtractPodSpec(service)
assert.Error(t, err, "service should not have an extractable pod spec")
}
func TestDefaultHasPodSpec(t *testing.T) {
podLikeResources := []schema.GroupResource{
corev1.Resource("pods"),
corev1.Resource("replicationcontrollers"),
corev1.Resource("podtemplates"),
appsv1.Resource("replicasets"),
appsv1.Resource("deployments"),
appsv1.Resource("statefulsets"),
appsv1.Resource("daemonsets"),
batchv1.Resource("jobs"),
batchv1.Resource("cronjobs"),
}
extractor := &DefaultPodSpecExtractor{}
for _, gr := range podLikeResources {
assert.True(t, extractor.HasPodSpec(gr), gr.String())
}
nonPodResources := []schema.GroupResource{
corev1.Resource("services"),
admissionv1.Resource("admissionreviews"),
appsv1.Resource("foobars"),
}
for _, gr := range nonPodResources {
assert.False(t, extractor.HasPodSpec(gr), gr.String())
}
}
type testEvaluator struct {
lv api.LevelVersion
}
func (t *testEvaluator) EvaluatePod(lv api.LevelVersion, meta *metav1.ObjectMeta, spec *corev1.PodSpec) []policy.CheckResult {
t.lv = lv
if meta.Annotations["error"] != "" {
return []policy.CheckResult{{Allowed: false, ForbiddenReason: meta.Annotations["error"]}}
} else {
return []policy.CheckResult{{Allowed: true}}
}
}
type testPodLister struct {
called bool
pods []*corev1.Pod
}
func (t *testPodLister) ListPods(ctx context.Context, namespace string) ([]*corev1.Pod, error) {
t.called = true
return t.pods, nil
}
func TestValidateNamespace(t *testing.T) {
testcases := []struct {
name string
exemptNamespaces []string
exemptRuntimeClasses []string
// override default policy
defaultPolicy *api.Policy
// request subresource
subresource string
// labels for the new namespace
newLabels map[string]string
// labels for the old namespace (only used if update=true)
oldLabels map[string]string
// list of pods to return
pods []*corev1.Pod
expectAllowed bool
expectError string
expectListPods bool
expectEvaluate api.LevelVersion
expectWarnings []string
}{
// creation tests, just validate labels
{
name: "create privileged",
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelPrivileged), api.EnforceVersionLabel: "v1.0"},
expectAllowed: true,
expectListPods: false,
},
{
name: "create baseline",
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline)},
expectAllowed: true,
expectListPods: false,
},
{
name: "create restricted",
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline)},
expectAllowed: true,
expectListPods: false,
},
{
name: "create malformed level",
newLabels: map[string]string{api.EnforceLevelLabel: "unknown"},
expectAllowed: false,
expectError: `must be one of privileged, baseline, restricted`,
expectListPods: false,
},
{
name: "create malformed version",
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelPrivileged), api.EnforceVersionLabel: "unknown"},
expectAllowed: false,
expectError: `must be "latest" or "v1.x"`,
expectListPods: false,
},
// update tests that don't tighten effective policy, no pod list/evaluate
{
name: "update no-op",
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"},
oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"},
expectAllowed: true,
expectListPods: false,
},
{
name: "update no-op malformed level",
newLabels: map[string]string{api.EnforceLevelLabel: "unknown"},
oldLabels: map[string]string{api.EnforceLevelLabel: "unknown"},
expectAllowed: true,
expectListPods: false,
},
{
name: "update no-op malformed version",
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline), api.EnforceVersionLabel: "unknown"},
oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline), api.EnforceVersionLabel: "unknown"},
expectAllowed: true,
expectListPods: false,
},
{
name: "update relax level identical version",
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline), api.EnforceVersionLabel: "v1.0"},
oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"},
expectAllowed: true,
expectListPods: false,
},
{
name: "update relax level explicit latest",
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline), api.EnforceVersionLabel: "latest"},
oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "latest"},
expectAllowed: true,
expectListPods: false,
},
{
name: "update relax level implicit latest",
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline)},
oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted)},
expectAllowed: true,
expectListPods: false,
},
{
name: "update to explicit privileged",
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelPrivileged)},
oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"},
expectAllowed: true,
expectListPods: false,
},
{
name: "update to implicit privileged",
newLabels: map[string]string{},
oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"},
expectAllowed: true,
expectListPods: false,
},
{
name: "update exempt to restricted",
exemptNamespaces: []string{"test"},
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"},
oldLabels: map[string]string{},
expectAllowed: true,
expectListPods: false,
},
// update tests that introduce labels errors
{
name: "update malformed level",
newLabels: map[string]string{api.EnforceLevelLabel: "unknown"},
oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"},
expectAllowed: false,
expectError: `must be one of privileged, baseline, restricted`,
expectListPods: false,
},
{
name: "update malformed version",
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelPrivileged), api.EnforceVersionLabel: "unknown"},
oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"},
expectAllowed: false,
expectError: `must be "latest" or "v1.x"`,
expectListPods: false,
},
// update tests that tighten effective policy
{
name: "update to implicit restricted",
newLabels: map[string]string{},
oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline), api.EnforceVersionLabel: "v1.0"},
defaultPolicy: &api.Policy{Enforce: api.LevelVersion{Level: api.LevelRestricted, Version: api.LatestVersion()}},
expectAllowed: true,
expectListPods: true,
expectEvaluate: api.LevelVersion{Level: api.LevelRestricted, Version: api.LatestVersion()},
expectWarnings: []string{"noruntimeclasspod: message", "runtimeclass1pod: message", "runtimeclass2pod: message"},
},
{
name: "update with runtimeclass exempt pods",
exemptRuntimeClasses: []string{"runtimeclass1"},
newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted)},
oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline)},
expectAllowed: true,
expectListPods: true,
expectEvaluate: api.LevelVersion{Level: api.LevelRestricted, Version: api.LatestVersion()},
expectWarnings: []string{"noruntimeclasspod: message", "runtimeclass2pod: message"},
},
// TODO: test for aggregating pods with identical warnings
// TODO: test for bounding evalution time with a warning
// TODO: test for bounding pod count with a warning
// TODO: test for prioritizing evaluating pods from unique controllers
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
newObject := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: tc.newLabels,
},
}
var operation = admission.Create
var oldObject runtime.Object
if tc.oldLabels != nil {
operation = admission.Update
oldObject = &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: tc.oldLabels,
},
}
}
attrs := admission.NewAttributesRecord(
newObject,
oldObject,
schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Namespace"},
newObject.Name,
newObject.Name,
schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"},
tc.subresource,
operation,
nil,
false,
nil,
)
defaultPolicy := api.Policy{
Enforce: api.LevelVersion{
Level: api.LevelPrivileged,
Version: api.LatestVersion(),
},
}
if tc.defaultPolicy != nil {
defaultPolicy = *tc.defaultPolicy
}
pods := tc.pods
if pods == nil {
pods = []*corev1.Pod{
{
ObjectMeta: metav1.ObjectMeta{Name: "noruntimeclasspod", Annotations: map[string]string{"error": "message"}},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "runtimeclass1pod", Annotations: map[string]string{"error": "message"}},
Spec: corev1.PodSpec{RuntimeClassName: pointer.String("runtimeclass1")},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "runtimeclass2pod", Annotations: map[string]string{"error": "message"}},
Spec: corev1.PodSpec{RuntimeClassName: pointer.String("runtimeclass2")},
},
}
}
podLister := &testPodLister{pods: pods}
evaluator := &testEvaluator{}
a := &Admission{
PodLister: podLister,
Evaluator: evaluator,
Configuration: &admissionapi.PodSecurityConfiguration{
Exemptions: admissionapi.PodSecurityExemptions{
Namespaces: tc.exemptNamespaces,
RuntimeClasses: tc.exemptRuntimeClasses,
},
},
defaultPolicy: defaultPolicy,
}
result := a.ValidateNamespace(context.TODO(), attrs)
if result.Allowed != tc.expectAllowed {
t.Errorf("expected allowed=%v, got %v", tc.expectAllowed, result.Allowed)
}
resultError := ""
if result.Result != nil {
resultError = result.Result.Message
}
if (len(resultError) > 0) != (len(tc.expectError) > 0) {
t.Errorf("expected error=%v, got %v", tc.expectError, resultError)
}
if len(tc.expectError) > 0 && !strings.Contains(resultError, tc.expectError) {
t.Errorf("expected error containing '%s', got %s", tc.expectError, resultError)
}
if podLister.called != tc.expectListPods {
t.Errorf("expected getPods=%v, got %v", tc.expectListPods, podLister.called)
}
if evaluator.lv != tc.expectEvaluate {
t.Errorf("expected to evaluate %v, got %v", tc.expectEvaluate, evaluator.lv)
}
if !reflect.DeepEqual(result.Warnings, tc.expectWarnings) {
t.Errorf("expected warnings:\n%v\ngot\n%v", tc.expectWarnings, result.Warnings)
}
})
}
}

View File

@ -0,0 +1,18 @@
/*
Copyright 2021 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package admission contains PodSecurity admission logic
package admission

View File

@ -0,0 +1,50 @@
/*
Copyright 2021 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package admission
import (
"context"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
corev1listers "k8s.io/client-go/listers/core/v1"
)
func NamespaceGetterFromClient(client kubernetes.Interface) NamespaceGetter {
return &namespaceGetter{client: client}
}
func NamespaceGetterFromListerAndClient(lister corev1listers.NamespaceLister, client kubernetes.Interface) NamespaceGetter {
return &namespaceGetter{lister: lister, client: client}
}
type namespaceGetter struct {
lister corev1listers.NamespaceLister
client kubernetes.Interface
}
func (n *namespaceGetter) GetNamespace(ctx context.Context, name string) (namespace *corev1.Namespace, err error) {
if n.lister != nil {
namespace, err := n.lister.Get(name)
if err == nil || !apierrors.IsNotFound(err) {
return namespace, err
}
}
return n.client.CoreV1().Namespaces().Get(ctx, name, metav1.GetOptions{})
}

View File

@ -0,0 +1,61 @@
/*
Copyright 2021 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package admission
import (
"context"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/kubernetes"
corev1listers "k8s.io/client-go/listers/core/v1"
)
// PodListerFromClient returns a PodLister that does live lists using the provided client.
func PodListerFromClient(client kubernetes.Interface) PodLister {
return &clientPodLister{client}
}
type clientPodLister struct {
client kubernetes.Interface
}
func (p *clientPodLister) ListPods(ctx context.Context, namespace string) ([]*corev1.Pod, error) {
list, err := p.client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
return nil, err
}
pods := make([]*corev1.Pod, len(list.Items))
for i := range list.Items {
pods[i] = &list.Items[i]
}
return pods, nil
}
// PodListerFromInformer returns a PodLister that does cached lists using the provided lister.
func PodListerFromInformer(lister corev1listers.PodLister) PodLister {
return &informerPodLister{lister}
}
type informerPodLister struct {
lister corev1listers.PodLister
}
func (p *informerPodLister) ListPods(ctx context.Context, namespace string) ([]*corev1.Pod, error) {
return p.lister.Pods(namespace).List(labels.Everything())
}