forked from kubernetes/kubernetes
-
Notifications
You must be signed in to change notification settings - Fork 104
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
UPSTREAM: <carry>: add metadataAdmission plugin
- Loading branch information
Showing
3 changed files
with
322 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
package metadata | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"io" | ||
|
||
"k8s.io/apimachinery/pkg/api/meta" | ||
"k8s.io/apimachinery/pkg/util/sets" | ||
"k8s.io/apiserver/pkg/admission" | ||
"k8s.io/klog/v2" | ||
) | ||
|
||
const PluginName = "operator.openshift.io/MetadataAdmission" | ||
|
||
func Register(plugins *admission.Plugins) { | ||
klog.InfoS("Registering OpenShift Metadata Admission Plugin") | ||
plugins.Register(PluginName, func(config io.Reader) (admission.Interface, error) { | ||
return newMetadataAdmission(), nil | ||
}) | ||
} | ||
|
||
func newMetadataAdmission() metadataAdmission { | ||
return metadataAdmission{ | ||
Handler: admission.NewHandler(admission.Update), | ||
badActors: sets.New( | ||
"system:serviceaccount:openshift-operators:strimzi-cluster-operator", | ||
"system:serviceaccount:openshift-operators-redhat:elasticsearch-operator", | ||
), | ||
} | ||
} | ||
|
||
// metadataAdmission plugin prevents a list of known "bad actors" | ||
// from overriding annotations they do not own. | ||
type metadataAdmission struct { | ||
*admission.Handler | ||
badActors sets.Set[string] | ||
} | ||
|
||
const protectedAnnotationKey = "openshift.io/internal-registry-pull-secret-ref" | ||
|
||
func (m metadataAdmission) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error { | ||
if a.GetResource().Resource != "serviceaccounts" { | ||
return nil | ||
} | ||
if a.GetUserInfo() == nil || !m.badActors.Has(a.GetUserInfo().GetName()) { | ||
// no user or not a known bad actor | ||
if klog.V(4).Enabled() { | ||
// log user if a protected annotation has changed | ||
if changed, err := annotationChanged(a, protectedAnnotationKey); err == nil && changed { | ||
klog.InfoS("Protected annotation changed", "ns", a.GetNamespace(), "sa", a.GetName(), "annotation", protectedAnnotationKey, "user", a.GetUserInfo().GetName()) | ||
} else if err != nil { | ||
klog.ErrorS(err, "Error checking annotation") | ||
} | ||
} | ||
return nil | ||
} | ||
changed, err := annotationChanged(a, protectedAnnotationKey) | ||
if err != nil { | ||
return err | ||
} | ||
if !changed { | ||
return nil | ||
} | ||
return admission.NewForbidden(a, fmt.Errorf("user '%s' is forbidden from altering the '%s' annotation on serviceaccounts", a.GetUserInfo().GetName(), protectedAnnotationKey)) | ||
} | ||
|
||
func annotationChanged(a admission.Attributes, key string) (bool, error) { | ||
before, err := meta.Accessor(a.GetOldObject()) | ||
if err != nil { | ||
return false, err | ||
} | ||
after, err := meta.Accessor(a.GetObject()) | ||
if err != nil { | ||
return false, err | ||
} | ||
valueBefore, keyExistedBefore := before.GetAnnotations()[key] | ||
valueAfter, keyExistsAfter := after.GetAnnotations()[key] | ||
if (keyExistedBefore == keyExistsAfter) && (valueBefore == valueAfter) { | ||
// annotation was not changed | ||
return false, nil | ||
} | ||
return true, nil | ||
} |
235 changes: 235 additions & 0 deletions
235
openshift-kube-apiserver/admission/metadata/admission_test.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,235 @@ | ||
package metadata | ||
|
||
import ( | ||
"context" | ||
"testing" | ||
|
||
corev1 "k8s.io/api/core/v1" | ||
"k8s.io/apimachinery/pkg/api/meta" | ||
"k8s.io/apimachinery/pkg/api/meta/testrestmapper" | ||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
"k8s.io/apimachinery/pkg/runtime" | ||
"k8s.io/apimachinery/pkg/runtime/schema" | ||
"k8s.io/apimachinery/pkg/util/sets" | ||
"k8s.io/apiserver/pkg/admission" | ||
auditinternal "k8s.io/apiserver/pkg/apis/audit" | ||
"k8s.io/apiserver/pkg/authentication/user" | ||
) | ||
|
||
func TestMetadataAdmissionValidate(t *testing.T) { | ||
|
||
_ = sa() | ||
testCases := []struct { | ||
name string | ||
forbidden sets.Set[string] | ||
attributes admission.Attributes | ||
wantError bool | ||
}{ | ||
{ | ||
name: "forbidden user edit", | ||
forbidden: sets.New[string]("operator1", "operator2"), | ||
attributes: attributes( | ||
withOldObject(sa(withProtectedAnnotationValue("before"))), | ||
withObject(sa(withProtectedAnnotationValue("after"))), | ||
withUser("operator1"), | ||
), | ||
wantError: true, | ||
}, | ||
{ | ||
name: "forbidden user delete", | ||
forbidden: sets.New[string]("operator1", "operator2"), | ||
attributes: attributes( | ||
withOldObject(sa(withProtectedAnnotationValue("before"))), | ||
withObject(sa()), | ||
withUser("operator1"), | ||
), | ||
wantError: true, | ||
}, | ||
{ | ||
name: "forbidden user add", | ||
forbidden: sets.New[string]("operator1", "operator2"), | ||
attributes: attributes( | ||
withObject(sa(withProtectedAnnotationValue("after"))), | ||
withOldObject(sa()), | ||
withUser("operator1"), | ||
), | ||
wantError: true, | ||
}, | ||
{ | ||
name: "forbidden user no change", | ||
forbidden: sets.New[string]("operator1", "operator2"), | ||
attributes: attributes( | ||
withOldObject(sa(withProtectedAnnotationValue("before"))), | ||
withObject(sa(withProtectedAnnotationValue("before"), withAnnotation("foo", "bar"))), | ||
withUser("operator1"), | ||
), | ||
wantError: false, | ||
}, | ||
{ | ||
name: "not forbidden user", | ||
forbidden: sets.New[string]("operator1", "operator2"), | ||
attributes: attributes( | ||
withOldObject(sa(withProtectedAnnotationValue("before"))), | ||
withObject(sa(withProtectedAnnotationValue("after"))), | ||
withOldObject(sa()), | ||
withUser("operator3"), | ||
), | ||
wantError: false, | ||
}, | ||
{ | ||
name: "no forbidden users", | ||
forbidden: sets.New[string](), | ||
attributes: attributes( | ||
withOldObject(sa(withProtectedAnnotationValue("before"))), | ||
withObject(sa(withProtectedAnnotationValue("after"))), | ||
withOldObject(sa()), | ||
withUser("operator1"), | ||
), | ||
wantError: false, | ||
}, | ||
{ | ||
name: "forbidden user, not service account", | ||
forbidden: sets.New[string]("operator1", "operator2"), | ||
attributes: attributes( | ||
withOldObject(pod(withProtectedAnnotationValue("before"))), | ||
withObject(pod(withProtectedAnnotationValue("after"))), | ||
withOldObject(sa()), | ||
withUser("operator1"), | ||
), | ||
wantError: false, | ||
}, | ||
} | ||
for _, tc := range testCases { | ||
t.Run(tc.name, func(t *testing.T) { | ||
err := metadataAdmission{badActors: tc.forbidden}.Validate(context.TODO(), tc.attributes, nil) | ||
if (err != nil) != tc.wantError { | ||
if tc.wantError { | ||
t.Fatal("expected error") | ||
} | ||
t.Fatalf("unexpected error: %v", err) | ||
} | ||
}) | ||
} | ||
|
||
} | ||
|
||
type attributesRecord struct { | ||
kind schema.GroupVersionKind | ||
namespace string | ||
name string | ||
resource schema.GroupVersionResource | ||
subresource string | ||
operation admission.Operation | ||
options runtime.Object | ||
dryRun bool | ||
object runtime.Object | ||
oldObject runtime.Object | ||
userInfo user.Info | ||
} | ||
|
||
func (a attributesRecord) GetName() string { return a.name } | ||
func (a attributesRecord) GetNamespace() string { return a.namespace } | ||
func (a attributesRecord) GetResource() schema.GroupVersionResource { return a.resource } | ||
func (a attributesRecord) GetSubresource() string { return a.subresource } | ||
func (a attributesRecord) GetOperation() admission.Operation { return a.operation } | ||
func (a attributesRecord) GetOperationOptions() runtime.Object { return a.options } | ||
func (a attributesRecord) IsDryRun() bool { return a.dryRun } | ||
func (a attributesRecord) GetObject() runtime.Object { return a.object } | ||
func (a attributesRecord) GetOldObject() runtime.Object { return a.oldObject } | ||
func (a attributesRecord) GetKind() schema.GroupVersionKind { return a.kind } | ||
func (a attributesRecord) GetUserInfo() user.Info { return a.userInfo } | ||
func (a attributesRecord) AddAnnotation(key, value string) error { panic("implement me") } | ||
func (a attributesRecord) AddAnnotationWithLevel(key, value string, level auditinternal.Level) error { | ||
panic("implement me") | ||
} | ||
func (a attributesRecord) GetReinvocationContext() admission.ReinvocationContext { | ||
panic("implement me") | ||
} | ||
|
||
func attributes(opts ...func(*attributesRecord)) *attributesRecord { | ||
a := &attributesRecord{ | ||
namespace: "test-ns", | ||
operation: admission.Update, | ||
options: &metav1.UpdateOptions{}, | ||
} | ||
for _, opt := range opts { | ||
opt(a) | ||
} | ||
return a | ||
} | ||
|
||
func withObject(o runtime.Object) func(*attributesRecord) { | ||
return func(record *attributesRecord) { | ||
record.object = o | ||
record.kind = o.GetObjectKind().GroupVersionKind() | ||
record.resource = gvk2gvr(record.kind) | ||
} | ||
} | ||
|
||
func gvk2gvr(gvk schema.GroupVersionKind) schema.GroupVersionResource { | ||
scheme := runtime.NewScheme() | ||
corev1.AddToScheme(scheme) | ||
mapper := testrestmapper.TestOnlyStaticRESTMapper(scheme) | ||
mapping, err := mapper.RESTMapping(gvk.GroupKind()) | ||
if err != nil { | ||
panic(err) | ||
} | ||
return mapping.Resource | ||
} | ||
|
||
func withOldObject(o runtime.Object) func(*attributesRecord) { | ||
return func(record *attributesRecord) { | ||
record.oldObject = o | ||
} | ||
} | ||
|
||
func withUser(u string) func(*attributesRecord) { | ||
return func(record *attributesRecord) { | ||
record.userInfo = &user.DefaultInfo{Name: u} | ||
} | ||
} | ||
|
||
func pod(opts ...func(runtime.Object)) *corev1.Pod { | ||
pod := &corev1.Pod{ | ||
TypeMeta: metav1.TypeMeta{ | ||
Kind: "Pod", | ||
APIVersion: "v1", | ||
}, | ||
} | ||
pod.Name = "test-pod" | ||
for _, opt := range opts { | ||
opt(pod) | ||
} | ||
return pod | ||
} | ||
|
||
func sa(opts ...func(runtime.Object)) *corev1.ServiceAccount { | ||
sa := &corev1.ServiceAccount{ | ||
TypeMeta: metav1.TypeMeta{ | ||
Kind: "ServiceAccount", | ||
APIVersion: "v1", | ||
}, | ||
} | ||
sa.Name = "test-sa" | ||
for _, opt := range opts { | ||
opt(sa) | ||
} | ||
return sa | ||
} | ||
|
||
func withProtectedAnnotationValue(v string) func(object runtime.Object) { | ||
return withAnnotation(protectedAnnotationKey, v) | ||
} | ||
|
||
func withAnnotation(k, v string) func(object runtime.Object) { | ||
return func(o runtime.Object) { | ||
m, err := meta.Accessor(o) | ||
if err != nil { | ||
panic(err) | ||
} | ||
if m.GetAnnotations() == nil { | ||
m.SetAnnotations(make(map[string]string)) | ||
} | ||
m.GetAnnotations()[k] = v | ||
} | ||
} |