mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-08-14 14:23:37 +00:00
Implement Kubelet AppArmor field handling
This commit is contained in:
parent
289ec02e8b
commit
bf3c8464ba
@ -18,6 +18,7 @@ package kuberuntime
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -28,6 +29,7 @@ import (
|
|||||||
runtimeapi "k8s.io/cri-api/pkg/apis/runtime/v1"
|
runtimeapi "k8s.io/cri-api/pkg/apis/runtime/v1"
|
||||||
"k8s.io/klog/v2"
|
"k8s.io/klog/v2"
|
||||||
kubecontainer "k8s.io/kubernetes/pkg/kubelet/container"
|
kubecontainer "k8s.io/kubernetes/pkg/kubelet/container"
|
||||||
|
"k8s.io/kubernetes/pkg/security/apparmor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type podsByID []*kubecontainer.Pod
|
type podsByID []*kubecontainer.Pod
|
||||||
@ -285,3 +287,35 @@ func (m *kubeGenericRuntimeManager) getSeccompProfile(annotations map[string]str
|
|||||||
ProfileType: runtimeapi.SecurityProfile_Unconfined,
|
ProfileType: runtimeapi.SecurityProfile_Unconfined,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getAppArmorProfile(pod *v1.Pod, container *v1.Container) (*runtimeapi.SecurityProfile, error) {
|
||||||
|
profile := apparmor.GetProfile(pod, container)
|
||||||
|
if profile == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch profile.Type {
|
||||||
|
case v1.AppArmorProfileTypeRuntimeDefault:
|
||||||
|
return &runtimeapi.SecurityProfile{
|
||||||
|
ProfileType: runtimeapi.SecurityProfile_RuntimeDefault,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case v1.AppArmorProfileTypeUnconfined:
|
||||||
|
return &runtimeapi.SecurityProfile{
|
||||||
|
ProfileType: runtimeapi.SecurityProfile_Unconfined,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case v1.AppArmorProfileTypeLocalhost:
|
||||||
|
if profile.LocalhostProfile == nil {
|
||||||
|
return nil, errors.New("missing localhost apparmor profile name")
|
||||||
|
}
|
||||||
|
return &runtimeapi.SecurityProfile{
|
||||||
|
ProfileType: runtimeapi.SecurityProfile_Localhost,
|
||||||
|
LocalhostRef: *profile.LocalhostProfile,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Shouldn't happen.
|
||||||
|
return nil, fmt.Errorf("unknown apparmor profile type: %q", profile.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -31,6 +31,7 @@ import (
|
|||||||
runtimetesting "k8s.io/cri-api/pkg/apis/testing"
|
runtimetesting "k8s.io/cri-api/pkg/apis/testing"
|
||||||
"k8s.io/kubernetes/pkg/features"
|
"k8s.io/kubernetes/pkg/features"
|
||||||
kubecontainer "k8s.io/kubernetes/pkg/kubelet/container"
|
kubecontainer "k8s.io/kubernetes/pkg/kubelet/container"
|
||||||
|
"k8s.io/utils/ptr"
|
||||||
)
|
)
|
||||||
|
|
||||||
type podStatusProviderFunc func(uid types.UID, name, namespace string) (*kubecontainer.PodStatus, error)
|
type podStatusProviderFunc func(uid types.UID, name, namespace string) (*kubecontainer.PodStatus, error)
|
||||||
@ -363,3 +364,75 @@ func TestToKubeContainerState(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetAppArmorProfile(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
podProfile *v1.AppArmorProfile
|
||||||
|
expectedProfile *runtimeapi.SecurityProfile
|
||||||
|
expectError bool
|
||||||
|
}{{
|
||||||
|
name: "no appArmor",
|
||||||
|
expectedProfile: nil,
|
||||||
|
}, {
|
||||||
|
name: "runtime default",
|
||||||
|
podProfile: &v1.AppArmorProfile{Type: v1.AppArmorProfileTypeRuntimeDefault},
|
||||||
|
expectedProfile: &runtimeapi.SecurityProfile{
|
||||||
|
ProfileType: runtimeapi.SecurityProfile_RuntimeDefault,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
name: "unconfined",
|
||||||
|
podProfile: &v1.AppArmorProfile{Type: v1.AppArmorProfileTypeUnconfined},
|
||||||
|
expectedProfile: &runtimeapi.SecurityProfile{
|
||||||
|
ProfileType: runtimeapi.SecurityProfile_Unconfined,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
name: "localhost",
|
||||||
|
podProfile: &v1.AppArmorProfile{
|
||||||
|
Type: v1.AppArmorProfileTypeLocalhost,
|
||||||
|
LocalhostProfile: ptr.To("test"),
|
||||||
|
},
|
||||||
|
expectedProfile: &runtimeapi.SecurityProfile{
|
||||||
|
ProfileType: runtimeapi.SecurityProfile_Localhost,
|
||||||
|
LocalhostRef: "test",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
name: "invalid localhost",
|
||||||
|
podProfile: &v1.AppArmorProfile{
|
||||||
|
Type: v1.AppArmorProfileTypeLocalhost,
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
}, {
|
||||||
|
name: "invalid type",
|
||||||
|
podProfile: &v1.AppArmorProfile{
|
||||||
|
Type: "foo",
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
pod := v1.Pod{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "bar",
|
||||||
|
},
|
||||||
|
Spec: v1.PodSpec{
|
||||||
|
SecurityContext: &v1.PodSecurityContext{
|
||||||
|
AppArmorProfile: test.podProfile,
|
||||||
|
},
|
||||||
|
Containers: []v1.Container{{Name: "foo"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
actual, err := getAppArmorProfile(&pod, &pod.Spec.Containers[0])
|
||||||
|
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, test.expectedProfile, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -20,7 +20,6 @@ import (
|
|||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
runtimeapi "k8s.io/cri-api/pkg/apis/runtime/v1"
|
runtimeapi "k8s.io/cri-api/pkg/apis/runtime/v1"
|
||||||
runtimeutil "k8s.io/kubernetes/pkg/kubelet/kuberuntime/util"
|
runtimeutil "k8s.io/kubernetes/pkg/kubelet/kuberuntime/util"
|
||||||
"k8s.io/kubernetes/pkg/security/apparmor"
|
|
||||||
"k8s.io/kubernetes/pkg/securitycontext"
|
"k8s.io/kubernetes/pkg/securitycontext"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -42,7 +41,10 @@ func (m *kubeGenericRuntimeManager) determineEffectiveSecurityContext(pod *v1.Po
|
|||||||
}
|
}
|
||||||
|
|
||||||
// set ApparmorProfile.
|
// set ApparmorProfile.
|
||||||
synthesized.ApparmorProfile = apparmor.GetProfileNameFromPodAnnotations(pod.Annotations, container.Name)
|
synthesized.Apparmor, err = getAppArmorProfile(pod, container)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// set RunAsUser.
|
// set RunAsUser.
|
||||||
if synthesized.RunAsUser == nil {
|
if synthesized.RunAsUser == nil {
|
||||||
|
@ -19,11 +19,29 @@ package apparmor
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
|
podutil "k8s.io/kubernetes/pkg/api/v1/pod"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Checks whether app armor is required for pod to be run.
|
// Checks whether app armor is required for the pod to run. AppArmor is considered required if any
|
||||||
|
// non-unconfined profiles are specified.
|
||||||
func isRequired(pod *v1.Pod) bool {
|
func isRequired(pod *v1.Pod) bool {
|
||||||
|
if pod.Spec.SecurityContext != nil && pod.Spec.SecurityContext.AppArmorProfile != nil &&
|
||||||
|
pod.Spec.SecurityContext.AppArmorProfile.Type != v1.AppArmorProfileTypeUnconfined {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
inUse := !podutil.VisitContainers(&pod.Spec, podutil.AllContainers, func(c *v1.Container, _ podutil.ContainerType) bool {
|
||||||
|
if c.SecurityContext != nil && c.SecurityContext.AppArmorProfile != nil &&
|
||||||
|
c.SecurityContext.AppArmorProfile.Type != v1.AppArmorProfileTypeUnconfined {
|
||||||
|
return false // is in use; short-circuit
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if inUse {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
for key, value := range pod.Annotations {
|
for key, value := range pod.Annotations {
|
||||||
if strings.HasPrefix(key, v1.AppArmorBetaContainerAnnotationKeyPrefix) {
|
if strings.HasPrefix(key, v1.AppArmorBetaContainerAnnotationKeyPrefix) {
|
||||||
return value != v1.AppArmorBetaProfileNameUnconfined
|
return value != v1.AppArmorBetaProfileNameUnconfined
|
||||||
@ -33,12 +51,49 @@ func isRequired(pod *v1.Pod) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetProfileName returns the name of the profile to use with the container.
|
// GetProfileName returns the name of the profile to use with the container.
|
||||||
func GetProfileName(pod *v1.Pod, containerName string) string {
|
func GetProfile(pod *v1.Pod, container *v1.Container) *v1.AppArmorProfile {
|
||||||
return GetProfileNameFromPodAnnotations(pod.Annotations, containerName)
|
if container.SecurityContext != nil && container.SecurityContext.AppArmorProfile != nil {
|
||||||
|
return container.SecurityContext.AppArmorProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static pods may not have had annotations synced to fields, so fallback to annotations before
|
||||||
|
// the pod profile.
|
||||||
|
if profile := getProfileFromPodAnnotations(pod.Annotations, container.Name); profile != nil {
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
if pod.Spec.SecurityContext != nil && pod.Spec.SecurityContext.AppArmorProfile != nil {
|
||||||
|
return pod.Spec.SecurityContext.AppArmorProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProfileNameFromPodAnnotations gets the name of the profile to use with container from
|
// getProfileFromPodAnnotations gets the AppArmor profile to use with container from
|
||||||
// pod annotations
|
// (deprecated) pod annotations.
|
||||||
func GetProfileNameFromPodAnnotations(annotations map[string]string, containerName string) string {
|
func getProfileFromPodAnnotations(annotations map[string]string, containerName string) *v1.AppArmorProfile {
|
||||||
return annotations[v1.AppArmorBetaContainerAnnotationKeyPrefix+containerName]
|
val, ok := annotations[v1.AppArmorBetaContainerAnnotationKeyPrefix+containerName]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case val == v1.AppArmorBetaProfileRuntimeDefault:
|
||||||
|
return &v1.AppArmorProfile{Type: v1.AppArmorProfileTypeRuntimeDefault}
|
||||||
|
|
||||||
|
case val == v1.AppArmorBetaProfileNameUnconfined:
|
||||||
|
return &v1.AppArmorProfile{Type: v1.AppArmorProfileTypeUnconfined}
|
||||||
|
|
||||||
|
case strings.HasPrefix(val, v1.AppArmorBetaProfileNamePrefix):
|
||||||
|
// Note: an invalid empty localhost profile will be rejected by kubelet admission.
|
||||||
|
profileName := strings.TrimPrefix(val, v1.AppArmorBetaProfileNamePrefix)
|
||||||
|
return &v1.AppArmorProfile{
|
||||||
|
Type: v1.AppArmorProfileTypeLocalhost,
|
||||||
|
LocalhostProfile: &profileName,
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Invalid annotation.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
124
pkg/security/apparmor/helpers_test.go
Normal file
124
pkg/security/apparmor/helpers_test.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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 apparmor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
v1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/utils/ptr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetProfile(t *testing.T) {
|
||||||
|
runtimeDefault := &v1.AppArmorProfile{Type: v1.AppArmorProfileTypeRuntimeDefault}
|
||||||
|
unconfined := &v1.AppArmorProfile{Type: v1.AppArmorProfileTypeUnconfined}
|
||||||
|
localhost := &v1.AppArmorProfile{
|
||||||
|
Type: v1.AppArmorProfileTypeLocalhost,
|
||||||
|
LocalhostProfile: ptr.To("test"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
annotationProfile string
|
||||||
|
containerProfile *v1.AppArmorProfile
|
||||||
|
podProfile *v1.AppArmorProfile
|
||||||
|
expectedProfile *v1.AppArmorProfile
|
||||||
|
}{{
|
||||||
|
name: "no appArmor",
|
||||||
|
expectedProfile: nil,
|
||||||
|
}, {
|
||||||
|
name: "pod profile",
|
||||||
|
podProfile: runtimeDefault,
|
||||||
|
expectedProfile: runtimeDefault,
|
||||||
|
}, {
|
||||||
|
name: "container profile",
|
||||||
|
containerProfile: unconfined,
|
||||||
|
expectedProfile: unconfined,
|
||||||
|
}, {
|
||||||
|
name: "annotation profile",
|
||||||
|
annotationProfile: v1.AppArmorBetaProfileNamePrefix + "test",
|
||||||
|
expectedProfile: localhost,
|
||||||
|
}, {
|
||||||
|
name: "invalid annotation",
|
||||||
|
annotationProfile: "invalid",
|
||||||
|
expectedProfile: nil,
|
||||||
|
}, {
|
||||||
|
name: "invalid annotation with pod field",
|
||||||
|
annotationProfile: "invalid",
|
||||||
|
podProfile: runtimeDefault,
|
||||||
|
expectedProfile: runtimeDefault,
|
||||||
|
}, {
|
||||||
|
name: "container field before annotation",
|
||||||
|
annotationProfile: v1.AppArmorBetaProfileNameUnconfined,
|
||||||
|
containerProfile: runtimeDefault,
|
||||||
|
expectedProfile: runtimeDefault,
|
||||||
|
}, {
|
||||||
|
name: "container field before pod field",
|
||||||
|
containerProfile: runtimeDefault,
|
||||||
|
podProfile: unconfined,
|
||||||
|
expectedProfile: runtimeDefault,
|
||||||
|
}, {
|
||||||
|
name: "annotation before pod field",
|
||||||
|
annotationProfile: v1.AppArmorBetaProfileNameUnconfined,
|
||||||
|
podProfile: runtimeDefault,
|
||||||
|
expectedProfile: unconfined,
|
||||||
|
}, {
|
||||||
|
name: "all profiles",
|
||||||
|
annotationProfile: v1.AppArmorBetaProfileRuntimeDefault,
|
||||||
|
containerProfile: localhost,
|
||||||
|
podProfile: unconfined,
|
||||||
|
expectedProfile: localhost,
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
container := v1.Container{
|
||||||
|
Name: "foo",
|
||||||
|
}
|
||||||
|
if test.containerProfile != nil {
|
||||||
|
container.SecurityContext = &v1.SecurityContext{
|
||||||
|
AppArmorProfile: test.containerProfile.DeepCopy(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pod := v1.Pod{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "bar",
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"unrelated": "baz",
|
||||||
|
v1.AppArmorBetaContainerAnnotationKeyPrefix + "other": v1.AppArmorBetaProfileRuntimeDefault,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: v1.PodSpec{
|
||||||
|
Containers: []v1.Container{container},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if test.annotationProfile != "" {
|
||||||
|
pod.Annotations[v1.AppArmorBetaContainerAnnotationKeyPrefix+container.Name] = test.annotationProfile
|
||||||
|
}
|
||||||
|
if test.podProfile != nil {
|
||||||
|
pod.Spec.SecurityContext = &v1.PodSecurityContext{
|
||||||
|
AppArmorProfile: test.podProfile.DeepCopy(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := GetProfile(&pod, &container)
|
||||||
|
assert.Equal(t, test.expectedProfile, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -25,7 +25,6 @@ import (
|
|||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||||
podutil "k8s.io/kubernetes/pkg/api/v1/pod"
|
podutil "k8s.io/kubernetes/pkg/api/v1/pod"
|
||||||
"k8s.io/kubernetes/pkg/apis/core/validation"
|
|
||||||
"k8s.io/kubernetes/pkg/features"
|
"k8s.io/kubernetes/pkg/features"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -62,15 +61,15 @@ func (v *validator) Validate(pod *v1.Pod) error {
|
|||||||
|
|
||||||
var retErr error
|
var retErr error
|
||||||
podutil.VisitContainers(&pod.Spec, podutil.AllContainers, func(container *v1.Container, containerType podutil.ContainerType) bool {
|
podutil.VisitContainers(&pod.Spec, podutil.AllContainers, func(container *v1.Container, containerType podutil.ContainerType) bool {
|
||||||
profile := GetProfileName(pod, container.Name)
|
profile := GetProfile(pod, container)
|
||||||
retErr = validation.ValidateAppArmorProfileFormat(profile)
|
if profile == nil {
|
||||||
if retErr != nil {
|
return true
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(#64841): This would ideally be part of validation.ValidateAppArmorProfileFormat, but
|
// TODO(#64841): This would ideally be part of validation.ValidateAppArmorProfileFormat, but
|
||||||
// that is called for API validation, and this is tightening validation.
|
// that is called for API validation, and this is tightening validation.
|
||||||
if strings.HasPrefix(profile, v1.AppArmorBetaProfileNamePrefix) {
|
if profile.Type == v1.AppArmorProfileTypeLocalhost {
|
||||||
if strings.TrimSpace(strings.TrimPrefix(profile, v1.AppArmorBetaProfileNamePrefix)) == "" {
|
if profile.LocalhostProfile == nil || strings.TrimSpace(*profile.LocalhostProfile) == "" {
|
||||||
retErr = fmt.Errorf("invalid empty AppArmor profile name: %q", profile)
|
retErr = fmt.Errorf("invalid empty AppArmor profile name: %q", profile)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,6 @@ func TestValidateValidHost(t *testing.T) {
|
|||||||
{v1.AppArmorBetaProfileNamePrefix + "docker-default", true},
|
{v1.AppArmorBetaProfileNamePrefix + "docker-default", true},
|
||||||
{v1.AppArmorBetaProfileNamePrefix + "foo-container", true},
|
{v1.AppArmorBetaProfileNamePrefix + "foo-container", true},
|
||||||
{v1.AppArmorBetaProfileNamePrefix + "/usr/sbin/ntpd", true},
|
{v1.AppArmorBetaProfileNamePrefix + "/usr/sbin/ntpd", true},
|
||||||
{"docker-default", false},
|
|
||||||
{v1.AppArmorBetaProfileNamePrefix + "", false}, // Empty profile explicitly forbidden.
|
{v1.AppArmorBetaProfileNamePrefix + "", false}, // Empty profile explicitly forbidden.
|
||||||
{v1.AppArmorBetaProfileNamePrefix + " ", false},
|
{v1.AppArmorBetaProfileNamePrefix + " ", false},
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user