Skip to content

Commit

Permalink
Add open policy agent filters (#2407)
Browse files Browse the repository at this point in the history
* Add Open Policy Agent based filters opaAuthorizeRequest() and opaServeResponse()

see also https://opensource.zalando.com/skipper/reference/filters/#open-policy-agent

Signed-off-by: Magnus Jungsbluth <[email protected]>
Signed-off-by: Sandor Szücs <[email protected]>
Co-authored-by: Sandor Szücs <[email protected]>
  • Loading branch information
mjungsbluth and szuecs authored Aug 14, 2023
1 parent e0dd155 commit 9ed7ece
Show file tree
Hide file tree
Showing 22 changed files with 2,815 additions and 16 deletions.
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.")
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

0 comments on commit 9ed7ece

Please sign in to comment.