Skip to content

Commit

Permalink
webhook: support validation rules
Browse files Browse the repository at this point in the history
Add new webhook admitter that evaluates Ingresses and RouteGroups
against a set of rules.

Each rule defines properties of matching resource and rejection message.

The implementation uses [Common Expression Language](https://github.com/google/cel-spec)
to match properties which is also used in [Kubernetes](https://kubernetes.io/docs/reference/using-api/cel/).

Signed-off-by: Alexander Yastrebov <alexander.yastrebov@zalando.de>
  • Loading branch information
AlexanderYastrebov committed Mar 1, 2024
1 parent 7041036 commit 99e05b3
Show file tree
Hide file tree
Showing 11 changed files with 416 additions and 1 deletion.
188 changes: 188 additions & 0 deletions cmd/webhook/admission/rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package admission

import (
"encoding/json"
"errors"
"fmt"
"strings"

"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
log "github.com/sirupsen/logrus"
"github.com/zalando/skipper/eskip"
"gopkg.in/yaml.v2"
)

type (
AdmissionRules struct {
Rules []AdmissionRule `json:"rules"`
}
AdmissionRule struct {
Reject string `json:"reject"`
When string `json:"when"`
}
)

type (
RuleAdmitter struct {
rules []*compiledRule
}

compiledRule struct {
reject string
program cel.Program
}
)

var _ admitter = &RuleAdmitter{}

func ParseRulesYaml(rulesYaml []byte) (*AdmissionRules, error) {
var ar AdmissionRules
if err := yaml.Unmarshal(rulesYaml, &ar); err != nil {
return nil, fmt.Errorf("failed to parse rules: %w", err)
}
return &ar, nil
}

func NewRuleAdmitter(rules *AdmissionRules) (*RuleAdmitter, error) {
compiledRules := make([]*compiledRule, 0, len(rules.Rules))
for i, rule := range rules.Rules {
cr, err := compileRule(&rule)
if err != nil {
return nil, fmt.Errorf("failed to compile rule %d: %w", i, err)
}
compiledRules = append(compiledRules, cr)
}
return &RuleAdmitter{rules: compiledRules}, nil
}

func compileRule(rule *AdmissionRule) (*compiledRule, error) {
env, err := cel.NewEnv(
cel.Variable("request", cel.MapType(cel.StringType, cel.DynType)),
//ext.Strings(),
cel.Function("eskipFilters", cel.MemberOverload("string_eskipFilters",
[]*cel.Type{cel.StringType}, cel.ListType(cel.DynType),
cel.UnaryBinding(eskipFilters),
)),
cel.Function("eskipFilter", cel.MemberOverload("string_eskipFilter",
[]*cel.Type{cel.StringType}, cel.DynType,
cel.UnaryBinding(eskipFilter),
)),
cel.Function("eskipRoutes", cel.MemberOverload("string_eskipRoutes",
[]*cel.Type{cel.StringType}, cel.ListType(cel.DynType),
cel.UnaryBinding(eskipRoutes),
)),
)
if err != nil {
return nil, err
}

ast, issues := env.Compile(rule.When)
if issues.Err() != nil {
return nil, fmt.Errorf("expression compile error: %w", issues.Err())
}

if ast.OutputType() != cel.BoolType {
return nil, fmt.Errorf("wrong expression output type: %v", ast.OutputType())
}

program, err := env.Program(ast, cel.EvalOptions(cel.OptOptimize))
if err != nil {
return nil, fmt.Errorf("program construction error: %w", err)
}

return &compiledRule{
reject: rule.Reject,
program: program,
}, nil
}

func eskipFilters(value ref.Val) ref.Val {
ff, err := eskip.ParseFilters(value.Value().(string))
if err != nil {
return types.WrapErr(fmt.Errorf("eskipFilters: %w", err))
}

var m []map[string]any
if err := convert(ff, &m); err != nil {
return types.WrapErr(fmt.Errorf("eskipFilters: %w", err))
}
return types.DefaultTypeAdapter.NativeToValue(m)
}

func eskipFilter(value ref.Val) ref.Val {
ff, err := eskip.ParseFilters(value.Value().(string))
if err != nil {
return types.WrapErr(fmt.Errorf("eskipFilter: %w", err))
}
if len(ff) != 1 {
return types.WrapErr(fmt.Errorf("eskipFilter: requires single filter"))
}

var m map[string]any
if err := convert(ff[0], &m); err != nil {
return types.WrapErr(fmt.Errorf("eskipFilter: %w", err))
}
return types.DefaultTypeAdapter.NativeToValue(m)
}

func eskipRoutes(value ref.Val) ref.Val {
rr, err := eskip.Parse(value.Value().(string))
if err != nil {
return types.WrapErr(fmt.Errorf("eskipRoutes: %w", err))
}

var m []map[string]any
if err := convert(rr, &m); err != nil {
return types.WrapErr(fmt.Errorf("eskipRoutes: %w", err))
}
return types.DefaultTypeAdapter.NativeToValue(m)
}

func convert(value any, resultPtr any) error {
if b, err := json.Marshal(value); err != nil {
return err
} else if err := json.Unmarshal(b, resultPtr); err != nil {
return err
}
return nil
}

func (a *RuleAdmitter) name() string {
return "rules"
}

func (a *RuleAdmitter) admit(req *admissionRequest) (*admissionResponse, error) {
// convert req to map[string]any
request := make(map[string]any)
if b, err := json.Marshal(req); err != nil {
return nil, fmt.Errorf("failed to convert request: %w", err)
} else if err := json.Unmarshal(b, &request); err != nil {
return nil, fmt.Errorf("failed to convert request: %w", err)
}

var rejectMessages []string
for i, rule := range a.rules {
matches, _, err := rule.program.Eval(map[string]any{"request": request})
if err != nil {
log.Errorf("Failed to evaluate rule %d: %v", i, err)
return nil, errors.New("invalid request") // hide details from the client
}
if matches.(types.Bool) {
rejectMessages = append(rejectMessages, rule.reject)
}
}

if len(rejectMessages) > 0 {
return &admissionResponse{
UID: req.UID,
Allowed: false,
Result: &status{Message: strings.Join(rejectMessages, ", ")},
}, nil
}
return &admissionResponse{
UID: req.UID,
Allowed: true,
}, nil
}
83 changes: 83 additions & 0 deletions cmd/webhook/admission/rules_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package admission

import (
"encoding/json"
"os"
"testing"

"github.com/ghodss/yaml"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRuleAdmitter(t *testing.T) {
if testing.Verbose() {
log.SetLevel(log.DebugLevel)
}

rulesYaml, err := os.ReadFile("testdata/rules/rules.yaml")
require.NoError(t, err)

rules, err := ParseRulesYaml(rulesYaml)
require.NoError(t, err)

ra, err := NewRuleAdmitter(rules)
require.NoError(t, err)

for _, tc := range []struct {
name string
input string
allowed bool
message string
}{
{
name: "allowed ingress",
input: "testdata/rules/ingress-allowed.yaml",
allowed: true,
},
{
name: "rejected ingress",
input: "testdata/rules/ingress-rejected.yaml",
allowed: false,
message: `Missing application label, see https://example.test/reference/labels-selectors/#application, ` +
`zalando.org/skipper-filter: oauthTokeninfoAnyScope filter uses "uid" scope, see https://opensource.zalando.com/skipper/reference/filters/#oauthtokeninfoanyscope, ` +
`zalando.org/skipper-routes: oauthTokeninfoAnyScope filter uses "uid" scope, see https://opensource.zalando.com/skipper/reference/filters/#oauthtokeninfoanyscope`,
},
{
name: "allowed routegroup",
input: "testdata/rules/routegroup-allowed.yaml",
allowed: true,
},
{
name: "rejected routegroup",
input: "testdata/rules/routegroup-rejected.yaml",
allowed: false,
message: `Missing application label, see https://example.test/reference/labels-selectors/#application, ` +
`oauthTokeninfoAnyScope filter uses "uid" scope, see https://opensource.zalando.com/skipper/reference/filters/#oauthtokeninfoanyscope`,
},
} {
t.Run(tc.name, func(t *testing.T) {
input, err := os.ReadFile(tc.input)
require.NoError(t, err)

y, err := yaml.YAMLToJSON(input)
require.NoError(t, err)

request := &admissionRequest{
UID: "test-uid",
Object: json.RawMessage(y),
}

resp, err := ra.admit(request)
require.NoError(t, err)
assert.Equal(t, request.UID, resp.UID)
assert.Equal(t, tc.allowed, resp.Allowed)

if tc.message != "" {
require.NotNil(t, resp.Result)
assert.Equal(t, tc.message, resp.Result.Message)
}
})
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
{
"request": {
"kind": {
"group": "networking.k8s.io",
"version": "v1",
"kind": "Ingress"
},
"uid": "req-uid",
"name": "req1",
"namespace": "n1",
"object": {
"metadata": {
"name": "ing1",
"namespace": "ing1",
"labels": {
"application": "foo"
},
"annotations": {
"zalando.org/skipper-filter": "status(200) -> inlineContent(\"This should work\")",
"zalando.org/skipper-predicate": "Header(\"test\", \"test\") && Path(\"/login\")",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"request": {
"kind": {
"group": "networking.k8s.io",
"version": "v1",
"kind": "Ingress"
},
"uid": "req-uid",
"name": "req1",
"namespace": "n1",
Expand Down
23 changes: 23 additions & 0 deletions cmd/webhook/admission/testdata/rules/ingress-allowed.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: foo
namespace: foo
labels:
application: foo
annotations:
zalando.org/skipper-filter: setRequestHeader("X-Foo", "Bar")
zalando.org/skipper-routes: |
p: Method("PATCH") -> "http://example.org";
spec:
rules:
- host: www.example.org
http:
paths:
- path: /
pathType: ImplementationSpecific
backend:
service:
name: bar
port:
name: baz
24 changes: 24 additions & 0 deletions cmd/webhook/admission/testdata/rules/ingress-rejected.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: foo
namespace: foo
# no application label
annotations:
# oauthTokeninfoAnyScope with uid scope
zalando.org/skipper-filter: oauthTokeninfoAnyScope("uid", "foo.read")
zalando.org/skipper-routes: |
p: Method("PATCH") -> status(405) -> <shunt>;
o: Method("OPTIONS") -> oauthTokeninfoAnyScope("uid", "foo.opts") -> <shunt>;
spec:
rules:
- host: www.example.org
http:
paths:
- path: /
pathType: ImplementationSpecific
backend:
service:
name: bar
port:
name: baz
19 changes: 19 additions & 0 deletions cmd/webhook/admission/testdata/rules/routegroup-allowed.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
apiVersion: zalando.org/v1
kind: RouteGroup
metadata:
name: foo
namespace: foo
labels:
application: foo
spec:
hosts:
- example.org
backends:
- name: app
type: service
serviceName: app-svc
servicePort: 80
routes:
- path: /
backends:
- backendName: app
19 changes: 19 additions & 0 deletions cmd/webhook/admission/testdata/rules/routegroup-rejected.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
apiVersion: zalando.org/v1
kind: RouteGroup
metadata:
name: foo
namespace: foo
spec:
hosts:
- example.org
backends:
- name: app
type: service
serviceName: app-svc
servicePort: 80
routes:
- path: /
filters:
- oauthTokeninfoAnyScope("uid", "foo.read")
backends:
- backendName: app
Loading

0 comments on commit 99e05b3

Please sign in to comment.