1
0
Fork 0
mirror of https://github.com/kyverno/kyverno.git synced 2025-03-28 02:18:15 +00:00

add support for Kubernetes API server POST (#6948)

* allow POST for Kubernetes API calls

Signed-off-by: Jim Bugwadia <jim@nirmata.com>

* add kuttl tests

Signed-off-by: Jim Bugwadia <jim@nirmata.com>

* fmt and undo local changes

Signed-off-by: Jim Bugwadia <jim@nirmata.com>

* fix codegen and unit test

Signed-off-by: Jim Bugwadia <jim@nirmata.com>

* fix unit test

Signed-off-by: Jim Bugwadia <jim@nirmata.com>

* fix tests and extends docs

Signed-off-by: Jim Bugwadia <jim@nirmata.com>

---------

Signed-off-by: Jim Bugwadia <jim@nirmata.com>
This commit is contained in:
Jim Bugwadia 2023-04-26 16:31:44 -07:00 committed by GitHub
parent deefe8eef3
commit 0c22858bbc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 3857 additions and 3705 deletions

View file

@ -7,7 +7,6 @@ import (
"github.com/sigstore/k8s-manifest-sigstore/pkg/k8smanifest"
admissionv1 "k8s.io/api/admission/v1"
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/pod-security-admission/api"
@ -116,12 +115,22 @@ type ConfigMapReference struct {
}
type APICall struct {
// URLPath is the URL path to be used in the HTTP GET request to the
// URLPath is the URL path to be used in the HTTP GET or POST request to the
// Kubernetes API server (e.g. "/api/v1/namespaces" or "/apis/apps/v1/deployments").
// The format required is the same format used by the `kubectl get --raw` command.
// See https://kyverno.io/docs/writing-policies/external-data-sources/#variables-from-kubernetes-api-server-calls
// for details.
// +kubebuilder:validation:Optional
URLPath string `json:"urlPath" yaml:"urlPath"`
// Method is the HTTP request type (GET or POST).
// +kubebuilder:default=GET
Method Method `json:"method,omitempty" yaml:"method,omitempty"`
// Data specifies the POST data sent to the server.
// +kubebuilder:validation:Optional
Data []RequestData `json:"data,omitempty" yaml:"data,omitempty"`
// Service is an API call to a JSON web service
// +kubebuilder:validation:Optional
Service *ServiceCall `json:"service,omitempty" yaml:"service,omitempty"`
@ -136,22 +145,14 @@ type APICall struct {
}
type ServiceCall struct {
// URL is the JSON web service URL.
// The typical format is `https://{service}.{namespace}:{port}/{path}`.
URL string `json:"urlPath" yaml:"urlPath"`
// URL is the JSON web service URL. A typical form is
// `https://{service}.{namespace}:{port}/{path}`.
URL string `json:"url" yaml:"url"`
// CABundle is a PEM encoded CA bundle which will be used to validate
// the server certificate.
// +kubebuilder:validation:Optional
CABundle string `json:"caBundle" yaml:"caBundle"`
// Method is the HTTP request type (GET or POST).
// +kubebuilder:default=GET
Method Method `json:"requestType" yaml:"requestType"`
// Data specifies the POST data sent to the server.
// +kubebuilder:validation:Optional
Data []RequestData `json:"data" yaml:"data"`
}
// Method is a HTTP request type.
@ -164,7 +165,7 @@ type RequestData struct {
Key string `json:"key" yaml:"key"`
// Value is the data value
Value *apiextensionsv1.JSON `json:"value" yaml:"value"`
Value *apiextv1.JSON `json:"value" yaml:"value"`
}
// Condition defines variable-based conditional criteria for rule execution.

View file

@ -32,10 +32,17 @@ import (
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *APICall) DeepCopyInto(out *APICall) {
*out = *in
if in.Data != nil {
in, out := &in.Data, &out.Data
*out = make([]RequestData, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.Service != nil {
in, out := &in.Service, &out.Service
*out = new(ServiceCall)
(*in).DeepCopyInto(*out)
**out = **in
}
}
@ -1166,13 +1173,6 @@ func (in *SecretReference) DeepCopy() *SecretReference {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceCall) DeepCopyInto(out *ServiceCall) {
*out = *in
if in.Data != nil {
in, out := &in.Data, &out.Data
*out = make([]RequestData, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceCall.

File diff suppressed because it is too large Load diff

View file

@ -143,6 +143,23 @@ spec:
server, or other JSON web service. The data returned is stored
in the context with the name for the context entry.
properties:
data:
description: Data specifies the POST data sent to the server.
items:
description: RequestData contains the HTTP POST data
properties:
key:
description: Key is a unique identifier for the data
value
type: string
value:
description: Value is the data value
x-kubernetes-preserve-unknown-fields: true
required:
- key
- value
type: object
type: array
jmesPath:
description: JMESPath is an optional JSON Match Expression
that can be used to transform the JSON response returned
@ -151,6 +168,13 @@ spec:
will return the total count of deployments across all
namespaces.
type: string
method:
default: GET
description: Method is the HTTP request type (GET or POST).
enum:
- GET
- POST
type: string
service:
description: Service is an API call to a JSON web service
properties:
@ -158,45 +182,20 @@ spec:
description: CABundle is a PEM encoded CA bundle which
will be used to validate the server certificate.
type: string
data:
description: Data specifies the POST data sent to the
server.
items:
description: RequestData contains the HTTP POST data
properties:
key:
description: Key is a unique identifier for the
data value
type: string
value:
description: Value is the data value
x-kubernetes-preserve-unknown-fields: true
required:
- key
- value
type: object
type: array
requestType:
default: GET
description: Method is the HTTP request type (GET or
POST).
enum:
- GET
- POST
type: string
urlPath:
description: URL is the JSON web service URL. The typical
format is `https://{service}.{namespace}:{port}/{path}`.
url:
description: URL is the JSON web service URL. A typical
form is `https://{service}.{namespace}:{port}/{path}`.
type: string
required:
- requestType
- urlPath
- url
type: object
urlPath:
description: URLPath is the URL path to be used in the HTTP
GET request to the Kubernetes API server (e.g. "/api/v1/namespaces"
or "/apis/apps/v1/deployments"). The format required
is the same format used by the `kubectl get --raw` command.
GET or POST request to the Kubernetes API server (e.g.
"/api/v1/namespaces" or "/apis/apps/v1/deployments").
The format required is the same format used by the `kubectl
get --raw` command. See https://kyverno.io/docs/writing-policies/external-data-sources/#variables-from-kubernetes-api-server-calls
for details.
type: string
type: object
configMap:

View file

@ -143,6 +143,23 @@ spec:
server, or other JSON web service. The data returned is stored
in the context with the name for the context entry.
properties:
data:
description: Data specifies the POST data sent to the server.
items:
description: RequestData contains the HTTP POST data
properties:
key:
description: Key is a unique identifier for the data
value
type: string
value:
description: Value is the data value
x-kubernetes-preserve-unknown-fields: true
required:
- key
- value
type: object
type: array
jmesPath:
description: JMESPath is an optional JSON Match Expression
that can be used to transform the JSON response returned
@ -151,6 +168,13 @@ spec:
will return the total count of deployments across all
namespaces.
type: string
method:
default: GET
description: Method is the HTTP request type (GET or POST).
enum:
- GET
- POST
type: string
service:
description: Service is an API call to a JSON web service
properties:
@ -158,45 +182,20 @@ spec:
description: CABundle is a PEM encoded CA bundle which
will be used to validate the server certificate.
type: string
data:
description: Data specifies the POST data sent to the
server.
items:
description: RequestData contains the HTTP POST data
properties:
key:
description: Key is a unique identifier for the
data value
type: string
value:
description: Value is the data value
x-kubernetes-preserve-unknown-fields: true
required:
- key
- value
type: object
type: array
requestType:
default: GET
description: Method is the HTTP request type (GET or
POST).
enum:
- GET
- POST
type: string
urlPath:
description: URL is the JSON web service URL. The typical
format is `https://{service}.{namespace}:{port}/{path}`.
url:
description: URL is the JSON web service URL. A typical
form is `https://{service}.{namespace}:{port}/{path}`.
type: string
required:
- requestType
- urlPath
- url
type: object
urlPath:
description: URLPath is the URL path to be used in the HTTP
GET request to the Kubernetes API server (e.g. "/api/v1/namespaces"
or "/apis/apps/v1/deployments"). The format required
is the same format used by the `kubectl get --raw` command.
GET or POST request to the Kubernetes API server (e.g.
"/api/v1/namespaces" or "/apis/apps/v1/deployments").
The format required is the same format used by the `kubectl
get --raw` command. See https://kyverno.io/docs/writing-policies/external-data-sources/#variables-from-kubernetes-api-server-calls
for details.
type: string
type: object
configMap:

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -545,9 +545,37 @@ string
</em>
</td>
<td>
<p>URLPath is the URL path to be used in the HTTP GET request to the
<p>URLPath is the URL path to be used in the HTTP GET or POST request to the
Kubernetes API server (e.g. &ldquo;/api/v1/namespaces&rdquo; or &ldquo;/apis/apps/v1/deployments&rdquo;).
The format required is the same format used by the <code>kubectl get --raw</code> command.</p>
The format required is the same format used by the <code>kubectl get --raw</code> command.
See <a href="https://kyverno.io/docs/writing-policies/external-data-sources/#variables-from-kubernetes-api-server-calls">https://kyverno.io/docs/writing-policies/external-data-sources/#variables-from-kubernetes-api-server-calls</a>
for details.</p>
</td>
</tr>
<tr>
<td>
<code>method</code><br/>
<em>
<a href="#kyverno.io/v1.Method">
Method
</a>
</em>
</td>
<td>
<p>Method is the HTTP request type (GET or POST).</p>
</td>
</tr>
<tr>
<td>
<code>data</code><br/>
<em>
<a href="#kyverno.io/v1.RequestData">
[]RequestData
</a>
</em>
</td>
<td>
<p>Data specifies the POST data sent to the server.</p>
</td>
</tr>
<tr>
@ -2274,7 +2302,7 @@ Please specify under &ldquo;any&rdquo; or &ldquo;all&rdquo; instead.</p>
(<code>string</code> alias)</p></h3>
<p>
(<em>Appears on:</em>
<a href="#kyverno.io/v1.ServiceCall">ServiceCall</a>)
<a href="#kyverno.io/v1.APICall">APICall</a>)
</p>
<p>
<p>Method is a HTTP request type.</p>
@ -2587,7 +2615,7 @@ RuleCountStatus
</h3>
<p>
(<em>Appears on:</em>
<a href="#kyverno.io/v1.ServiceCall">ServiceCall</a>)
<a href="#kyverno.io/v1.APICall">APICall</a>)
</p>
<p>
<p>RequestData contains the HTTP POST data</p>
@ -3178,14 +3206,14 @@ string
<tbody>
<tr>
<td>
<code>urlPath</code><br/>
<code>url</code><br/>
<em>
string
</em>
</td>
<td>
<p>URL is the JSON web service URL.
The typical format is <code>https://{service}.{namespace}:{port}/{path}</code>.</p>
<p>URL is the JSON web service URL. A typical form is
<code>https://{service}.{namespace}:{port}/{path}</code>.</p>
</td>
</tr>
<tr>
@ -3200,32 +3228,6 @@ string
the server certificate.</p>
</td>
</tr>
<tr>
<td>
<code>requestType</code><br/>
<em>
<a href="#kyverno.io/v1.Method">
Method
</a>
</em>
</td>
<td>
<p>Method is the HTTP request type (GET or POST).</p>
</td>
</tr>
<tr>
<td>
<code>data</code><br/>
<em>
<a href="#kyverno.io/v1.RequestData">
[]RequestData
</a>
</em>
</td>
<td>
<p>Data specifies the POST data sent to the server.</p>
</td>
</tr>
</tbody>
</table>
<hr />

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"time"
kubeutils "github.com/kyverno/kyverno/pkg/utils/kube"
@ -31,7 +32,7 @@ type Interface interface {
// SetDiscovery sets the discovery client implementation
SetDiscovery(discoveryClient IDiscovery)
// RawAbsPath performs a raw call to the kubernetes API
RawAbsPath(ctx context.Context, path string) ([]byte, error)
RawAbsPath(ctx context.Context, path string, method string, dataReader io.Reader) ([]byte, error)
// GetResource returns the resource in unstructured/json format
GetResource(ctx context.Context, apiVersion string, kind string, namespace string, name string, subresources ...string) (*unstructured.Unstructured, error)
// PatchResource patches the resource
@ -141,11 +142,20 @@ func (c *client) GetResource(ctx context.Context, apiVersion string, kind string
}
// RawAbsPath performs a raw call to the kubernetes API
func (c *client) RawAbsPath(ctx context.Context, path string) ([]byte, error) {
func (c *client) RawAbsPath(ctx context.Context, path string, method string, dataReader io.Reader) ([]byte, error) {
if c.rest == nil {
return nil, errors.New("rest client not supported")
}
return c.rest.Get().RequestURI(path).DoRaw(ctx)
switch method {
case "GET":
return c.rest.Get().RequestURI(path).DoRaw(ctx)
case "POST":
return c.rest.Post().Body(dataReader).RequestURI(path).DoRaw(ctx)
default:
return nil, fmt.Errorf("method not supported: %s", method)
}
}
// PatchResource patches the resource

View file

@ -69,33 +69,38 @@ func (a *apiCall) Execute(ctx context.Context) ([]byte, error) {
func (a *apiCall) execute(ctx context.Context, call *kyvernov1.APICall) ([]byte, error) {
if call.URLPath != "" {
return a.executeK8sAPICall(ctx, call.URLPath)
return a.executeK8sAPICall(ctx, call.URLPath, call.Method, call.Data)
}
return a.executeServiceCall(ctx, call.Service)
return a.executeServiceCall(ctx, call)
}
func (a *apiCall) executeK8sAPICall(ctx context.Context, path string) ([]byte, error) {
jsonData, err := a.client.RawAbsPath(ctx, path)
if err != nil {
return nil, fmt.Errorf("failed to get resource with raw url\n: %s: %v", path, err)
}
a.logger.V(4).Info("executed APICall", "name", a.entry.Name, "len", len(jsonData))
return jsonData, nil
}
func (a *apiCall) executeServiceCall(ctx context.Context, service *kyvernov1.ServiceCall) ([]byte, error) {
if service == nil {
return nil, fmt.Errorf("missing service for APICall %s", a.entry.Name)
}
client, err := a.buildHTTPClient(service)
func (a *apiCall) executeK8sAPICall(ctx context.Context, path string, method kyvernov1.Method, data []kyvernov1.RequestData) ([]byte, error) {
requestData, err := a.buildRequestData(data)
if err != nil {
return nil, err
}
req, err := a.buildHTTPRequest(ctx, service)
jsonData, err := a.client.RawAbsPath(ctx, path, string(method), requestData)
if err != nil {
return nil, fmt.Errorf("failed to %v resource with raw url\n: %s: %v", method, path, err)
}
a.logger.V(4).Info("executed APICall", "name", a.entry.Name, "path", path, "method", method, "len", len(jsonData))
return jsonData, nil
}
func (a *apiCall) executeServiceCall(ctx context.Context, apiCall *kyvernov1.APICall) ([]byte, error) {
if apiCall.Service == nil {
return nil, fmt.Errorf("missing service for APICall %s", a.entry.Name)
}
client, err := a.buildHTTPClient(apiCall.Service)
if err != nil {
return nil, err
}
req, err := a.buildHTTPRequest(ctx, apiCall)
if err != nil {
return nil, fmt.Errorf("failed to build HTTP request for APICall %s: %w", a.entry.Name, err)
}
@ -124,7 +129,11 @@ func (a *apiCall) executeServiceCall(ctx context.Context, service *kyvernov1.Ser
return body, nil
}
func (a *apiCall) buildHTTPRequest(ctx context.Context, service *kyvernov1.ServiceCall) (req *http.Request, err error) {
func (a *apiCall) buildHTTPRequest(ctx context.Context, apiCall *kyvernov1.APICall) (req *http.Request, err error) {
if apiCall.Service == nil {
return nil, fmt.Errorf("missing service")
}
token := a.getToken()
defer func() {
if token != "" && req != nil {
@ -132,22 +141,22 @@ func (a *apiCall) buildHTTPRequest(ctx context.Context, service *kyvernov1.Servi
}
}()
if service.Method == "GET" {
req, err = http.NewRequestWithContext(ctx, "GET", service.URL, nil)
if apiCall.Method == "GET" {
req, err = http.NewRequestWithContext(ctx, "GET", apiCall.Service.URL, nil)
return
}
if service.Method == "POST" {
data, dataErr := a.buildPostData(service.Data)
if apiCall.Method == "POST" {
data, dataErr := a.buildRequestData(apiCall.Data)
if dataErr != nil {
return nil, dataErr
}
req, err = http.NewRequest("POST", service.URL, data)
req, err = http.NewRequest("POST", apiCall.Service.URL, data)
return
}
return nil, fmt.Errorf("invalid request type %s for APICall %s", service.Method, a.entry.Name)
return nil, fmt.Errorf("invalid request type %s for APICall %s", apiCall.Method, a.entry.Name)
}
func (a *apiCall) getToken() string {
@ -162,7 +171,7 @@ func (a *apiCall) getToken() string {
}
func (a *apiCall) buildHTTPClient(service *kyvernov1.ServiceCall) (*http.Client, error) {
if service.CABundle == "" {
if service == nil || service.CABundle == "" {
return http.DefaultClient, nil
}
caCertPool := x509.NewCertPool()
@ -180,7 +189,7 @@ func (a *apiCall) buildHTTPClient(service *kyvernov1.ServiceCall) (*http.Client,
}, nil
}
func (a *apiCall) buildPostData(data []kyvernov1.RequestData) (io.Reader, error) {
func (a *apiCall) buildRequestData(data []kyvernov1.RequestData) (io.Reader, error) {
dataMap := make(map[string]interface{})
for _, d := range data {
dataMap[d.Key] = d.Value

View file

@ -59,7 +59,7 @@ func Test_serviceGetRequest(t *testing.T) {
_, err = call.Execute(context.TODO())
assert.ErrorContains(t, err, "invalid request type")
entry.APICall.Service.Method = "GET"
entry.APICall.Method = "GET"
call, err = New(logr.Discard(), jp, entry, ctx, nil)
assert.NilError(t, err)
_, err = call.Execute(context.TODO())
@ -83,9 +83,9 @@ func Test_servicePostRequest(t *testing.T) {
entry := kyvernov1.ContextEntry{
Name: "test",
APICall: &kyvernov1.APICall{
Method: "POST",
Service: &kyvernov1.ServiceCall{
URL: s.URL + "/resource",
Method: "POST",
URL: s.URL + "/resource",
},
},
}
@ -130,7 +130,7 @@ func Test_servicePostRequest(t *testing.T) {
err = ctx.AddContextEntry("images", []byte(imageData))
assert.NilError(t, err)
entry.APICall.Service.Data = []kyvernov1.RequestData{
entry.APICall.Data = []kyvernov1.RequestData{
{
Key: "images",
Value: &apiextensionsv1.JSON{

View file

@ -379,7 +379,11 @@ func Validate(policy, oldPolicy kyvernov1.PolicyInterface, client dclient.Interf
allKinds = append(allKinds, matchKinds...)
allKinds = append(allKinds, excludeKinds...)
if rule.HasValidate() {
validationJson, err := json.Marshal(rule.Validation)
validationElem := rule.Validation.DeepCopy()
if validationElem.Deny != nil {
validationElem.Deny.RawAnyAllConditions = nil
}
validationJson, err := json.Marshal(validationElem)
if err != nil {
return nil, err
}
@ -1062,8 +1066,18 @@ func validateConfigMap(entry kyvernov1.ContextEntry) error {
}
func validateAPICall(entry kyvernov1.ContextEntry) error {
// If JMESPath contains variables, the validation will fail because it's not possible to infer which value
// will be inserted by the variable
if entry.APICall == nil {
return nil
}
if entry.APICall.URLPath != "" {
if entry.APICall.Service != nil {
return fmt.Errorf("a URLPath cannot be used for service API calls")
}
}
// If JMESPath contains variables, the validation will fail because it's not
// possible to infer which value will be inserted by the variable
// Skip validation if a variable is detected
jmesPath := variables.ReplaceAllVars(entry.APICall.JMESPath, func(s string) string { return "kyvernojmespathvariable" })

View file

@ -0,0 +1,9 @@
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: check-subjectaccessreview
status:
conditions:
- reason: Succeeded
status: "True"
type: Ready

View file

@ -0,0 +1,81 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/component: admission-controller
app.kubernetes.io/instance: kyverno
app.kubernetes.io/part-of: kyverno
name: kyverno:subjectaccessreviews
rules:
- apiGroups:
- authorization.k8s.io
resources:
- subjectaccessreviews
verbs:
- '*'
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/component: admission-controller
app.kubernetes.io/instance: kyverno
app.kubernetes.io/part-of: kyverno
name: kyverno:namespace-delete
rules:
- apiGroups:
- ""
resources:
- namespaces
verbs:
- delete
resourceNames:
- test-sar
---
apiVersion: v1
kind: Namespace
metadata:
name: test-sar
---
apiVersion: kyverno.io/v2beta1
kind: ClusterPolicy
metadata:
name: check-subjectaccessreview
spec:
validationFailureAction: Enforce
background: false
rules:
- name: check-sar
match:
any:
- resources:
kinds:
- ConfigMap
context:
- name: subjectaccessreview
apiCall:
urlPath: /apis/authorization.k8s.io/v1/subjectaccessreviews
method: POST
data:
- key: kind
value: SubjectAccessReview
- key: apiVersion
value: authorization.k8s.io/v1
- key: spec
value:
resourceAttributes:
resource: namespaces
name: "{{ request.namespace }}"
verb: "delete"
group: ""
#user: "{{ request.userInfo.username }}"
user: "system:serviceaccount:kyverno:kyverno-admission-controller"
validate:
message: "User is not authorized."
deny:
conditions:
any:
- key: "{{ subjectaccessreview.status.allowed }}"
operator: NotEquals
value: true

View file

@ -0,0 +1,7 @@
apiVersion: kuttl.dev/v1beta1
kind: TestStep
apply:
- file: cm-default-ns.yaml
shouldFail: true
- file: cm-test-ns.yaml
shouldFail: false

View file

@ -0,0 +1,13 @@
## Description
This test checks a POST operation to the Kubernetes API server for a SubjectAccessReview. It checks for delete access to the namespace of the request, and allows or denies the request.
## Expected Behavior
The test resource should be allowed to be created in the test namespace but not in the `default` namespace, as Kyverno cannot delete it.
## Reference Issues
https://github.com/kyverno/kyverno/issues/1717
https://github.com/kyverno/kyverno/issues/6857

View file

@ -0,0 +1,6 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: cm
namespace: default
data: {}

View file

@ -0,0 +1,6 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: cm
namespace: test-sar
data: {}