-
Notifications
You must be signed in to change notification settings - Fork 350
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
a22ca19
commit f34f663
Showing
10 changed files
with
426 additions
and
5 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,201 @@ | ||
package admission | ||
|
||
import ( | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"os" | ||
"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 NewRuleAdmitterFrom(rulesFile string) (*RuleAdmitter, error) { | ||
rulesYaml, err := os.ReadFile(rulesFile) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
rules, err := ParseRulesYaml(rulesYaml) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
return NewRuleAdmitter(rules) | ||
} | ||
|
||
func compileRule(rule *AdmissionRule) (*compiledRule, error) { | ||
env, err := cel.NewEnv( | ||
cel.Variable("object", 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 | ||
object := make(map[string]any) | ||
if err := json.Unmarshal(req.Object, &object); err != nil { | ||
return nil, fmt.Errorf("failed to convert request object: %w", err) | ||
} | ||
|
||
var rejectMessages []string | ||
for i, rule := range a.rules { | ||
matches, _, err := rule.program.Eval(map[string]any{"object": object}) | ||
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 | ||
} |
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,77 @@ | ||
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) | ||
} | ||
|
||
ra, err := NewRuleAdmitterFrom("testdata/rules/rules.yaml") | ||
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) | ||
} | ||
}) | ||
} | ||
} |
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,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
24
cmd/webhook/admission/testdata/rules/ingress-rejected.yaml
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,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
19
cmd/webhook/admission/testdata/rules/routegroup-allowed.yaml
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,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 |
21 changes: 21 additions & 0 deletions
21
cmd/webhook/admission/testdata/rules/routegroup-rejected.yaml
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,21 @@ | ||
apiVersion: zalando.org/v1 | ||
kind: RouteGroup | ||
metadata: | ||
name: foo | ||
namespace: foo | ||
# no application label | ||
spec: | ||
hosts: | ||
- example.org | ||
backends: | ||
- name: app | ||
type: service | ||
serviceName: app-svc | ||
servicePort: 80 | ||
routes: | ||
- path: / | ||
filters: | ||
# oauthTokeninfoAnyScope with uid scope | ||
- oauthTokeninfoAnyScope("uid", "foo.read") | ||
backends: | ||
- backendName: app |
Oops, something went wrong.