Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add open policy agent filters #2407

Merged
merged 24 commits into from
Aug 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9b320e4
Add Open Policy Agent based filters
mjungsbluth Jun 19, 2023
0a8fda4
Add tracing
mjungsbluth Jun 5, 2023
b608ca4
Review comments + metrics
mjungsbluth Jun 8, 2023
4e4e219
Metrics for serveresponsewithregopolicy, review comments
mjungsbluth Jun 8, 2023
1abaaa7
Review comments (2nd batch), quiet logging
mjungsbluth Jun 14, 2023
6bd0c12
Correctly use filter context tracer
mjungsbluth Jun 19, 2023
9615c32
Add OPA labels to Span
mjungsbluth Jun 20, 2023
338ea1b
Add ops documentation / prefix tags on Span
mjungsbluth Jun 20, 2023
23f1c01
fix: filters should be ptr receiver and spec should return a filters.…
szuecs Jun 23, 2023
dd30210
Make tracing work without parent Span
mjungsbluth Jun 23, 2023
f0ce637
Re-use OPA instances, review comments
mjungsbluth Jun 25, 2023
43b2c5c
Fall back to request span as parent
mjungsbluth Jun 26, 2023
e9e161f
Align span name
mjungsbluth Jun 26, 2023
786fd22
Correct operation name
mjungsbluth Jun 28, 2023
9c472ac
Review comments
mjungsbluth Jul 10, 2023
058101b
Update to OPA 0.54
mjungsbluth Jul 10, 2023
663c79a
Wrap error
mjungsbluth Jul 10, 2023
6efffd9
Change from Close() to PostProcessor for clean-up
mjungsbluth Jul 10, 2023
d675798
Clean tracing
mjungsbluth Jul 10, 2023
2897593
Implement missing method after OPA upgrade
mjungsbluth Aug 8, 2023
5227601
Avoid making a copy of the routes.
mjungsbluth Aug 8, 2023
2025717
Use assert.NoError
mjungsbluth Aug 8, 2023
f7689c7
Review comments. Test coverage.
mjungsbluth Aug 14, 2023
6be33e5
Rename filters
mjungsbluth Aug 14, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/zalando/skipper"
"github.com/zalando/skipper/dataclients/kubernetes"
"github.com/zalando/skipper/eskip"
"github.com/zalando/skipper/filters/openpolicyagent"
"github.com/zalando/skipper/net"
"github.com/zalando/skipper/proxy"
"github.com/zalando/skipper/swarm"
Expand Down Expand Up @@ -273,6 +274,11 @@ type Config struct {

LuaModules *listFlag `yaml:"lua-modules"`
LuaSources *listFlag `yaml:"lua-sources"`

EnableOpenPolicyAgent bool `yaml:"enable-open-policy-agent"`
OpenPolicyAgentConfigTemplate string `yaml:"open-policy-agent-config-template"`
OpenPolicyAgentEnvoyMetadata string `yaml:"open-policy-agent-envoy-metadata"`
OpenPolicyAgentCleanerInterval time.Duration `yaml:"open-policy-agent-cleaner-interval"`
}

const (
Expand Down Expand Up @@ -483,6 +489,10 @@ func NewConfig() *Config {
flag.DurationVar(&cfg.OidcDistributedClaimsTimeout, "oidc-distributed-claims-timeout", 2*time.Second, "sets the default OIDC distributed claims request timeout duration to 2000ms")
flag.Var(cfg.CredentialPaths, "credentials-paths", "directories or files to watch for credentials to use by bearerinjector filter")
flag.DurationVar(&cfg.CredentialsUpdateInterval, "credentials-update-interval", 10*time.Minute, "sets the interval to update secrets")
flag.BoolVar(&cfg.EnableOpenPolicyAgent, "enable-open-policy-agent", false, "enables Open Policy Agent filters")
flag.StringVar(&cfg.OpenPolicyAgentConfigTemplate, "open-policy-agent-config-template", "", "file containing a template for an Open Policy Agent configuration file that is interpolated for each OPA filter instance")
flag.StringVar(&cfg.OpenPolicyAgentEnvoyMetadata, "open-policy-agent-envoy-metadata", "", "JSON file containing meta-data passed as input for compatibility with Envoy policies in the format")
flag.DurationVar(&cfg.OpenPolicyAgentCleanerInterval, "open-policy-agent-cleaner-interval", openpolicyagent.DefaultCleanIdlePeriod, "JSON file containing meta-data passed as input for compatibility with Envoy policies in the format")

// TLS client certs
flag.StringVar(&cfg.ClientKeyFile, "client-tls-key", "", "TLS Key file for backend connections, multiple keys may be given comma separated - the order must match the certs")
Expand Down Expand Up @@ -879,6 +889,11 @@ func (c *Config) ToOptions() skipper.Options {

LuaModules: c.LuaModules.values,
LuaSources: c.LuaSources.values,

EnableOpenPolicyAgent: c.EnableOpenPolicyAgent,
OpenPolicyAgentConfigTemplate: c.OpenPolicyAgentConfigTemplate,
OpenPolicyAgentEnvoyMetadata: c.OpenPolicyAgentEnvoyMetadata,
OpenPolicyAgentCleanerInterval: c.OpenPolicyAgentCleanerInterval,
}
for _, rcci := range c.CloneRoute {
eskipClone := eskip.NewClone(rcci.Reg, rcci.Repl)
Expand Down
1 change: 1 addition & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ func defaultConfig() *Config {
ValidateQueryLog: true,
LuaModules: commaListFlag(),
LuaSources: commaListFlag(),
OpenPolicyAgentCleanerInterval: 10 * time.Second,
}
}

Expand Down
26 changes: 26 additions & 0 deletions docs/operation/operation.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,22 @@ by the default, and exposed among the timers via the following keys:

See more details about rate limiting at [Rate limiting](../reference/filters.md#clusterclientratelimit).

### Open Policy Agent metrics

If Open Policy Agent filters are enabled, the following counters show up in the `/metrics` endpoint. The bundle-name is the first parameter of the filter so that for example increased error codes can be attributed to a specific source bundle / system.

- `skipper.opaAuthorizeRequest.custom.decision.allow.<bundle-name>`
- `skipper.opaAuthorizeRequest.custom.decision.deny.<bundle-name>`
- `skipper.opaAuthorizeRequest.custom.decision.err.<bundle-name>`
- `skipper.opaServeResponse.custom.decision.allow.<bundle-name>`
- `skipper.opaServeResponse.custom.decision.deny.<bundle-name>`
- `skipper.opaServeResponse.custom.decision.err.<bundle-name>`

The following timer metrics are exposed per used bundle-name:

- `skipper.opaAuthorizeRequest.custom.eval_time.<bundle-name>`
- `skipper.opaServeResponse.custom.eval_time.<bundle-name>`

## OpenTracing

Skipper has support for different [OpenTracing API](http://opentracing.io/) vendors, including
Expand Down Expand Up @@ -612,6 +628,16 @@ connect, TLS handshake and connection pool:

![tokeninfo auth filter span with logs](../img/skipper_opentracing_auth_filter_tokeninfo_span_with_logs.png)

### Open Policy Agent span

When one of the Open Policy Agent filters is used, child spans with the operation name `open-policy-agent` are added to the Trace.

The following tags are added to the Span, labels are taken from the OPA configuration YAML file as is and are not interpreted:
- `opa.decision_id=<decision id that was recorded>`
- `opa.labels.<label1>=<value1>`

The labels can for example be used to link to a specific decision in the control plane if they contain URL fragments for the receiving entity.

### Redis rate limiting spans

#### Operation: redis_allow_check_card
Expand Down
122 changes: 122 additions & 0 deletions docs/reference/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -1719,6 +1719,128 @@ oidcClaimsQuery("/:name%\"*One\"", "/path:groups.#[%\"*-Test-Users\"] groups.#[=

As of now there is no negative/deny rule possible. The first matching path is evaluated against the defined query/queries and if positive, permitted.

### Open Policy Agent

To get started with [Open Policy Agent](https://www.openpolicyagent.org/), also have a look at the [tutorial](../tutorials/auth.md#open-policy-agent). This section is only a reference for the implemented filters.

#### opaAuthorizeRequest

The canonical use case that is also implemented with [Envoy External Authorization](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter): Use the http request to evaluate if Skipper should deny the request (with customizable response) or let the request pass to the downstream service

Example:

```
opaAuthorizeRequest("my-app-id")
```

Example (passing context):
```
opaAuthorizeRequest("my-app-id", "com.mydomain.xxx.myprop: myvalue")
```

*Data Flows*

The data flow in case the policy allows the request looks like this

```ascii
┌──────────────────┐ ┌────────────────────┐
(1) Request │ Skipper │ (4) Request │ Target Aplication │
─────────────┤ ├──────────────►│ │
│ │ │ │
(6) Response│ (2)│ ▲ (3) │ (5) Response │ │
◄────────────┤Req ->│ │ allow │◄──────────────┤ │
│Input │ │ │ │ │
├──────┴───┴───────┤ └────────────────────┘
│Open Policy Agent │
│ │ │ │
│ │ │ │
│ │ │ │
│ ▼ │ │
│ ┌────────┴─────┐ │
│ │ Policy │ │
│ └──────────────┘ │
│ │
└──────────────────┘

```

In Step (2) the http request is transformed into an input object following the [Envoy structure](https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/auth/v3/external_auth.proto#service-auth-v3-checkrequest) that is also used by the OPA Envoy plugin. In (3) the decision of the policy is evaluated. If it is equivalent to an "allow", the remaining steps are executed as without the filter.

The data flow in case the policy disallows the request looks like this

```ascii
┌──────────────────┐ ┌────────────────────┐
(1) Request │ Skipper │ │ Target Aplication │
─────────────┤ ├──────────────►│ │
│ │ │ │
(4) Response│ (2)│ ▲ (3) │ │ │
◄────────────┤Req ->│ │ allow │◄──────────────┤ │
│Input │ │ =false│ │ │
├──────┴───┴───────┤ └────────────────────┘
│Open Policy Agent │
│ │ │ │
│ │ │ │
│ │ │ │
│ ▼ │ │
│ ┌────────┴─────┐ │
│ │ Policy │ │
│ └──────────────┘ │
│ │
└──────────────────┘

```

The difference is that if the decision in (3) is equivalent to false, the response is handled directly from the filter. If the decision contains response body, status or headers those are used to build the response in (6) otherwise a 403 Forbidden with a generic body is returned.

*Manipulating Request Headers*

Headers both to the upstream and the downstream service can be manipulated the same way this works for [Envoy external authorization](https://www.openpolicyagent.org/docs/latest/envoy-primer/#example-policy-with-additional-controls)

This allows both to add and remove unwanted headers in allow/deny cases.

#### opaServeResponse

Always serves the response even if the policy allows the request and can customize the response completely. Can be used to re-implement legacy authorization services by already using data in Open Policy Agent but implementing an old REST API. This can also be useful to support Single Page Applications to return the calling users' permissions.

*Hint*: As there is no real allow/deny in this case and the policy computes the http response, you typically will want to [drop all decision logs](https://www.openpolicyagent.org/docs/latest/management-decision-logs/#drop-decision-logs)

Example:

```
opaServeResponse("my-app-id")
```

Example (passing context):
```
opaServeResponse("my-app-id", "com.mydomain.xxx.myprop: myvalue")
```

*Data Flows*

For this filter, the data flow looks like this independent of an allow/deny decision

```ascii
┌──────────────────┐
(1) Request │ Skipper │
─────────────┤ ├
│ │
(4) Response│ (2)│ ▲ (3) │
◄────────────┤Req ->│ │ resp │
│Input │ │ │
├──────┴───┴───────┤
│Open Policy Agent │
│ │ │ │
│ │ │ │
│ │ │ │
│ ▼ │ │
│ ┌────────┴─────┐ │
│ │ Policy │ │
│ └──────────────┘ │
│ │
└──────────────────┘

```

## Cookie Handling
### dropRequestCookie

Expand Down
86 changes: 86 additions & 0 deletions docs/tutorials/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,3 +415,89 @@ scope by doing the following:

> The subject is the field that identifies the user and is often called `sub`,
> especially in the context of OpenID Connect. In the example above, it is `username`.

## Open Policy Agent

To enable [Open Policy Agent](https://www.openpolicyagent.org/) filter, use the `-enable-open-policy-agent` command line flag.

Open Policy Agent is integrated as a Go library so no extra setup is needed to run. Every filter creates a virtual OPA instance in memory that is configured using a configuration file in the same [configuration format](https://www.openpolicyagent.org/docs/latest/configuration/) that a standalone OPA would use. To allow for configurability, the configuration file is interpolated using Go Templates to allow every virtual instance to pull different bundles. This template file is passed using the `-open-policy-agent-config-template` flag.

### Configuration File

As an example the following initial config can be used

```yaml
services:
- name: bundle-service
url: https://my-example-opa-bucket.s3.eu-central-1.amazonaws.com
credentials:
s3_signing:
environment_credentials: {}
labels:
environment: production
discovery:
name: discovery
prefix: "/applications/{{ .bundlename }}"
```

The variable `.bundlename` is the first argument in the following filters and can be in any format that OPA can understand, so for example application IDs from a registry, uuids, ...

### Input Structures

Input structures to policies follow those that are used by the [opa-envoy-plugin](https://github.com/open-policy-agent/opa-envoy-plugin), the existing [examples and documentation](https://www.openpolicyagent.org/docs/latest/envoy-primer/#example-input) apply also to Skipper. Please note that the filters in Skipper always generate v3 input structures.

### Passing context to the policy

Generally there are two ways to pass context to a policy:

1. as part of the labels in Open Policy Agent (configured in the configuration file, see below) that should be used for deployment level taxonomy,
2. as part of so called context extensions that are part of the Envoy external auth specification.

This context can be passed as second argument to filters:

`opaAuthorizeRequest("my-app-id", "com.mycompany.myprop: myvalue")`
or `opaAuthorizeRequest("my-app-id", "{'com.mycompany.myprop': 'my value'}")`

The second argument is parsed as YAML, cannot be nested and values need to be strings.

In Rego this can be used like this `input.attributes.contextExtensions["com.mycompany.myprop"] == "my value"`

### Quick Start Rego Playground

A quick way without setting up Backend APIs is to use the [Rego Playground](https://play.openpolicyagent.org/).

To get started pick from examples Envoy > Hello World. Click on "Publish" and note the random ID in the section "Run OPA with playground policy".

Place the following file in your local directory with the name `opaconfig.yaml`

```yaml
bundles:
play:
resource: bundles/{{ .bundlename }}
polling:
long_polling_timeout_seconds: 45
services:
- name: play
url: https://play.openpolicyagent.org
plugins:
envoy_ext_authz_grpc:
# This needs to match the package, defaulting to envoy/authz/allow
path: envoy/http/public/allow
dry-run: false
decision_logs:
console: true
```

Start Skipper with

```
skipper -enable-open-policy-agent -open-policy-agent-config-template opaconfig.yaml \
-inline-routes 'notfound: * -> opaAuthorizeRequest("<playground-bundle-id>") -> inlineContent("<h1>Authorized Hello</h1>") -> <shunt>'
```

You can test the policy with

- Authorized: `curl http://localhost:9090/ -i`
- Authorized: `curl http://localhost:9090/foobar -H "Authorization: Basic charlie" -i`
- Forbidden: `curl http://localhost:9090/foobar -i`

2 changes: 2 additions & 0 deletions filters/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,8 @@ const (
EndpointCreatedName = "endpointCreated"
ConsistentHashKeyName = "consistentHashKey"
ConsistentHashBalanceFactorName = "consistentHashBalanceFactor"
OpaAuthorizeRequestName = "opaAuthorizeRequest"
OpaServeResponseName = "opaServeResponse"

// Undocumented filters
HealthCheckName = "healthcheck"
Expand Down
73 changes: 73 additions & 0 deletions filters/openpolicyagent/evaluation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package openpolicyagent

import (
"context"
"fmt"
"time"

ext_authz_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
"github.com/open-policy-agent/opa-envoy-plugin/envoyauth"
"github.com/open-policy-agent/opa-envoy-plugin/opa/decisionlog"
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/rego"
"github.com/open-policy-agent/opa/server"
"github.com/open-policy-agent/opa/tracing"
"github.com/opentracing/opentracing-go"
)

func (opa *OpenPolicyAgentInstance) Eval(ctx context.Context, req *ext_authz_v3.CheckRequest) (*envoyauth.EvalResult, error) {
result, stopeval, err := envoyauth.NewEvalResult()
span := opentracing.SpanFromContext(ctx)
if span != nil {
span.SetTag("opa.decision_id", result.DecisionID)
}

if err != nil {
opa.Logger().WithFields(map[string]interface{}{"err": err}).Error("Unable to generate decision ID.")
AlexanderYastrebov marked this conversation as resolved.
Show resolved Hide resolved
return nil, err
}

var input map[string]interface{}
defer func() {
stopeval()
err := opa.logDecision(ctx, input, result, err)
if err != nil {
opa.Logger().WithFields(map[string]interface{}{"err": err}).Error("Unable to log decision to control plane.")
}
}()

if ctx.Err() != nil {
return nil, fmt.Errorf("check request timed out before query execution: %w", ctx.Err())
}

logger := opa.manager.Logger().WithFields(map[string]interface{}{"decision-id": result.DecisionID})
input, err = envoyauth.RequestToInput(req, logger, nil, true)
if err != nil {
return nil, fmt.Errorf("failed to convert request to input: %w", err)
}

inputValue, err := ast.InterfaceToValue(input)
if err != nil {
return nil, err
}

err = envoyauth.Eval(ctx, opa, inputValue, result, rego.DistributedTracingOpts(tracing.Options{opa}))
if err != nil {
return nil, err
}

return result, nil
}

func (opa *OpenPolicyAgentInstance) logDecision(ctx context.Context, input interface{}, result *envoyauth.EvalResult, err error) error {
info := &server.Info{
Timestamp: time.Now(),
Input: &input,
}

if opa.EnvoyPluginConfig().Path != "" {
info.Path = opa.EnvoyPluginConfig().Path
}

return decisionlog.LogDecision(ctx, opa.manager, info, result, err)
}
Loading