Recently, wanted to understand and use the external authorization server since i specialize in authn/authz quite a bit for my job. In digging into it earlier today, i found a number of amazing sample that this post is based on:
- @Josh Barratt's awesome post about Envoy + Custom Auth + Ratelimiter Example
- @Rick Lee's Envoy External Authorization — A simple example
This post is about how to get a basic "hello world" app using envoy.ext_authz
where any authorization decision a envoy request makes is handled by an external gRPC service you would run. You can pretty much offload each decision to let a request through based on some very specific rule you define. You of course do not have to use an external server for simple checks like JWT authentication based on claims or issuer (for that just use Envoy's built-in JWT-Authentication). Use this if you run Envoy directly and wish to make a decision based on some other complex criteria not covered by the others.
This tutorial runs an an Envoy Proxy, a simple http backend and a gRPC service which envoy delegates the authorization check to. You can take pertty much anyting out of the original inbound request context (headers, etc) to make a allow/deny decision on as well as append/alter headers)
Before we get started, a word from our sponsors ...here are some of the other references you maybe interested in
- Simple Istio Mixer Out of Process Authorization Adapter
- Envoy RateLimit HelloWorld
- Envoy ControlPlane HelloWorld
- Envoy Discovery Service HelloWorld
Well...its pretty straight forward as you'd expect
- Client makes HTTP request
- Envoy sends inbound request to an external Authorization server
- External authorization server makes a decision given the request context
- If authorized, the request is sent through
Steps 2,3 is encapsulated as a gRPC proto external_auth.proto where the request response context is set:
// A generic interface for performing authorization check on incoming
// requests to a networked service.
service Authorization {
// Performs authorization check based on the attributes associated with the
// incoming request, and returns status `OK` or not `OK`.
rpc Check(v2.CheckRequest) returns (v2.CheckResponse);
}
What that means is our gRPC external server needs to implement the Check()
service..
Anyway, lets get started. You'll need:
You can simple use docker-compose file the testing.
docker-compose up --remove-orphans --build --force-recreate
The backend here is a simple http webserver that will print the inbound headers and add one in the response (X-Custom-Header-From-Backend
).
$ go run backend_server/http_server.go
$ go run authz_server/grpc_server.go
The core of the authorization server isn't really anything special...i've just hardcoded it to look for a header value of 'foo' through...you can add on any bit of complex handling here you want.
func (a *AuthorizationServer) Check(ctx context.Context, req *auth.CheckRequest) (*auth.CheckResponse, error) {
log.Println(">>> Authorization called check()")
authHeader, ok := req.Attributes.Request.Http.Headers["authorization"]
var splitToken []string
if ok {
splitToken = strings.Split(authHeader, "Bearer ")
}
if len(splitToken) == 2 {
token := splitToken[1]
if token == "foo" {
return &auth.CheckResponse{
Status: &rpcstatus.Status{
Code: int32(rpc.OK),
},
HttpResponse: &auth.CheckResponse_OkResponse{
OkResponse: &auth.OkHttpResponse{
Headers: []*core.HeaderValueOption{
{
Header: &core.HeaderValue{
Key: "x-custom-header-from-authz",
Value: "some value",
},
},
},
},
},
}, nil
} else {
return &auth.CheckResponse{
Status: &rpcstatus.Status{
Code: int32(rpc.PERMISSION_DENIED),
},
HttpResponse: &auth.CheckResponse_DeniedResponse{
DeniedResponse: &auth.DeniedHttpResponse{
Status: &envoy_type.HttpStatus{
Code: envoy_type.StatusCode_Unauthorized,
},
Body: "PERMISSION_DENIED",
},
},
}, nil
}
}
return &auth.CheckResponse{
Status: &rpcstatus.Status{
Code: int32(rpc.UNAUTHENTICATED),
},
HttpResponse: &auth.CheckResponse_DeniedResponse{
DeniedResponse: &auth.DeniedHttpResponse{
Status: &envoy_type.HttpStatus{
Code: envoy_type.StatusCode_Unauthorized,
},
Body: "Authorization Header malformed or not provided",
},
},
}, nil
}
$ envoy -c basic.yaml -l info
The envoy confg settings describe ext-authz as well as a set of custom headers to send to the client and the authorization checker (i'll discuss that bit later on in the doc)
filter_chains:
- filters:
- name: envoy.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager
stat_prefix: ingress_http
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match:
prefix: "/"
route:
# host_rewrite: server.domain.com
cluster: service_backend
request_headers_to_add:
- header:
key: x-custom-to-backend
value: value-for-backend-from-envoy
# per_filter_config:
# envoy.ext_authz:
# check_settings:
# context_extensions:
# x-forwarded-host: original-host-as-context
http_filters:
- name: envoy.ext_authz
config:
grpc_service:
envoy_grpc:
cluster_name: ext-authz
timeout: 0.5s
- name: envoy.lua
config:
inline_code: |
function envoy_on_request(request_handle)
request_handle:logInfo('>>> LUA envoy_on_request Called')
--buf = request_handle:body()
--bufbytes = buf:getBytes(0, buf:length())
--request_handle:logInfo(bufbytes)
end
function envoy_on_response(response_handle)
response_handle:logInfo('>>> LUA envoy_on_response Called')
response_handle:headers():add("X-Custom-Header-From-LUA", "bar")
end
clusters:
- name: ext-authz
type: static
http2_protocol_options: {}
load_assignment:
cluster_name: ext-authz
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 50051
The moment you start envoy, it will start sending gRPC healthcheck requests to the backend. That bit isn't related to authorization services but i thouht it'd be nice to add into envoy's config. For more info, see the part where the backend requests are made here in this the generic grpc_health_proxy
- No Header
$ curl -vv -w "\n" http://localhost:8080/
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.66.0
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< content-length: 46
< content-type: text/plain
< date: Sat, 09 Nov 2019 17:22:39 GMT
< server: envoy
<
Authorization Header malformed or not provided
$ curl -vv -H "Authorization: Bearer bar" -w "\n" http://localhost:8080/
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.66.0
> Accept: */*
> Authorization: Bearer bar
* Mark bundle as not supporting multiuse
< HTTP/1.1 401 Unauthorized
< content-length: 17
< content-type: text/plain
< date: Sat, 09 Nov 2019 17:25:14 GMT
< server: envoy
<
PERMISSION_DENIED
$ curl -vv -H "Authorization: Bearer foo" -w "\n" http://localhost:8080/
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.66.0
> Accept: */*
> Authorization: Bearer foo
< HTTP/1.1 200 OK
< x-custom-header-from-backend: from backend
< date: Sat, 09 Nov 2019 17:26:06 GMT
< content-length: 2
< content-type: text/plain; charset=utf-8
< x-envoy-upstream-service-time: 0
< x-custom-header-from-lua: bar
< server: envoy
ok
If you want to add a custom metadata/header to just the authorization server that was not included in the original request (eg to address envoy issue #3876, consider using the attribute_context extension
In the configuration above, if you send a request fom the with these headers
Client:
$ curl -vv -H "Authorization: Bearer foo" -H "Host: s2.domain.com" -H "foo: bar" http://localhost:8080/
> GET / HTTP/1.1
> Host: s2.domain.com
> User-Agent: curl/7.66.0
> Accept: */*
> Authorization: Bearer foo
> foo: bar
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< x-custom-header-from-backend: from backend
< date: Mon, 11 Nov 2019 19:19:44 GMT
< content-length: 2
< content-type: text/plain; charset=utf-8
< x-envoy-upstream-service-time: 0
< x-custom-header-from-lua: bar
< server: envoy
<
ok
External Authorization server will see an additional context value sent "x-forwarded-host"
which you can use to make decision.
$ go run authz_server/grpc_server.go
2019/11/11 11:19:39 Starting gRPC Server at :50051
2019/11/11 11:19:42 Handling grpc Check request
2019/11/11 11:19:44 >>> Authorization called check()
2019/11/11 11:19:44 Inbound Headers:
2019/11/11 11:19:44 {
":authority": "s2.domain.com",
":method": "GET",
":path": "/",
"accept": "*/*",
"authorization": "Bearer foo",
"foo": "bar",
"user-agent": "curl/7.66.0",
"x-forwarded-proto": "http",
"x-request-id": "86c79873-b145-4e82-8e7c-800ecb0ba931"
}
2019/11/11 11:19:44 Context Extensions:
2019/11/11 11:19:44 {
"x-forwarded-host": "original-host-as-context"
}
Finally, the backend system will not see that custom header but all the others you specified
$ go run backend_server/http_server.go
2019/11/11 11:19:42 Starting Server..
2019/11/11 11:19:44 / called
GET / HTTP/1.1
Host: server.domain.com
Accept: */*
Authorization: Bearer foo
Content-Length: 0
Foo: bar
User-Agent: curl/7.66.0
X-Custom-Header-From-Authz: some value
X-Custom-To-Backend: value-for-backend-from-envoy
X-Envoy-Expected-Rq-Timeout-Ms: 15000
X-Forwarded-Proto: http
X-Request-Id: 86c79873-b145-4e82-8e7c-800ecb0ba931
Thats it...but realistically, you probably would be fine with using Envoy's built-in capabilities or with Open Policy Agent or even Istio Authorization. This repo is just a demo of stand-alone Envoy.