背景
工作中的一个 IAM(身份与访问管理)服务中,使用到了 OPA(开放策略代理)进行鉴权,针对前端来的一个请求,主要处理逻辑如下图红色箭头所示:
下面对各个步骤进行说明:
- 1:客户端的 web 请求,通过 nginx ingress 进入集群
- 2:Nginx 通过 认证子请求,将请求发送给 oathkeeper 进行认证
- 3:Oathkeeper 返回 JWT 给 nginx
- 4a:Nginx 将 JWT 放在“Authorization”请求头中,发送给应用程序
- 5a:应用程序进行简单处理后,请求当前 pod 的 OPA 容器进行鉴权
- 6a:OPA 容器根据策略(启动时从 IAM 获取的)返回决策为 true 或者 false,应用程序根据结果决定是否响应请求
但是以上流程存在一个严重问题,鉴权和应用程序耦合,每个应用程序都需要添加 OPA 的请求和处理逻辑,工作量大且后期有更新时不好维护。因此考虑将鉴权和业务逻辑解耦,如图中黑色箭头所示:
- 4b:Nginx 请求应用程序 pod 时,envoy 容器将流量劫持
- 5b:Envoy 将请求转发给 OPA 容器进行鉴权
- 6b:OPA 容器将鉴权结果返回给 envoy
- 7b:Envoy 收到鉴权结果为 true,则将请求转发给应用程序,否则返回 403
这个思路在 OPA 官网上可以找到,即 这个教程示例。后续内容主要基于此示例,并增加一些详细说明。
Envoy 介绍
Envoy 是专为大型现代 SOA(面向服务架构)架构设计的 L7 代理和通信总线,体积小,性能高。它的诞生源于以下理念:对应用程序而言,网络应该是透明的,当网络和应用程序出现故障时,应该能够很容易确定问题的根源。
Envoy具有以下优点:
- 非侵入的架构,即 Sidecar 模式
- 同时支持 L3/L4/L7 代理
- 支持 HTTP/2、gRPC
- 服务发现和动态配置
- 支持流量统计和分布式追踪等可观察性功能
OPA 介绍
OPA(Open Policy Agent,开放策略代理)是一个开源的通用策略引擎,支持跨整个堆栈的、统一的、上下文感知的决策。OPA 将策略与决策分离开来,当你的软件需要做出决策时,就可以使用结构化数据请求 OPA,其原理如下图所示:
OPA 的策略可以用 Rego 编写,详细描述参考 Rego。
入门实践
内容基于 教程示例,进行了部分修改,并增加一些详细说明。
k8s 集群环境
本地环境为一台安装了 k3s 集群的云主机,k3s 环境脚本为:
1
2
|
set -x
curl -sfL https://rancher-mirror.rancher.cn/k3s/k3s-install.sh | INSTALL_K3S_MIRROR=cn sh -
|
创建 Envoy 配置
下面的配置使用了外部鉴权过滤器 envoy.ext_authz,来请求 gRPC 鉴权服务器,配置为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
|
static_resources:
listeners:
- address:
socket_address:
address: 0.0.0.0
port_value: 8000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: backend
domains:
- "*"
routes:
- match:
prefix: "/"
route:
cluster: service
http_filters:
- name: envoy.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
transport_api_version: V3
with_request_body:
max_request_bytes: 8192
allow_partial_message: true
failure_mode_allow: false
grpc_service:
google_grpc:
target_uri: 127.0.0.1:9191
stat_prefix: ext_authz
timeout: 0.5s
- name: envoy.filters.http.router
clusters:
- name: service
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
load_assignment:
cluster_name: service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 8080
admin:
access_log_path: "/dev/null"
address:
socket_address:
address: 0.0.0.0
port_value: 8001
layered_runtime:
layers:
- name: static_layer_0
static_layer:
envoy:
resource_limits:
listener:
example_listener_name:
connection_limit: 10000
overload:
global_downstream_max_connections: 50000
|
下面对其中部分配置进行说明,首先外面几层配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
static_resources: # 包含启动时静态配置的所有内容
listeners: # 监听器
- address: # 监听地址
socket_address:
address: 0.0.0.0
port_value: 8000
filter_chains: # 过滤器链
...
clusters: # 上游集群配置,在过滤器路由中会用到
- name: service
...
admin: # 管理员配置
access_log_path: "/dev/null"
address: # 管理员服务地址
...
|
对过滤器进行说明:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
- filters:
- name: envoy.filters.network.http_connection_manager # 过滤器限定名称,这里是 http 连接管理器
typed_config: # http 连接管理器的类型
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: auto # 编解码器类型
stat_prefix: ingress_http # 为连接管理器发生统计时的前缀
route_config: # 路由配置,在配置 envoy.filters.http.router 时会使用
name: local_route
virtual_hosts: # 虚拟主机
- name: backend
domains: # 匹配的 domain
- "*"
routes:
- match: # 匹配的 url
prefix: "/"
route:
cluster: service
http_filters: # 使用的 http 过滤器
- name: envoy.ext_authz # http 过滤器限定名称
typed_config: # http 过滤器类型
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
transport_api_version: V3 # 协议版本
with_request_body: # 转发请求体设置
max_request_bytes: 8192
allow_partial_message: true
grpc_service: # 转发 grpc 地址
google_grpc:
target_uri: 127.0.0.1:9191
stat_prefix: ext_authz
timeout: 0.5s
- name: envoy.filters.http.router # 使用路由过滤器,typed_config 只有一种,进行了省略
|
clusters 配置中,大部分都比较简单,其中 lb_policy 是负载均衡策略,这里设置的是 round_robin。将 envoy 配置在 k8s 中进行部署:
1
|
kubectl create configmap proxy-config --from-file envoy.yaml
|
创建 OPA 策略配置
这里对示例应用程序公开的/people 端点, 设置 OPA 策略来实现以下鉴权限制:
- Alice 被授予访客角色,可以对/people 执行 GET 请求。
- Bob 被授予管理员角色,可以对/people 执行 GET 和 POST 请求。
对应的 policy.rego 为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
package envoy.authz
import future.keywords
import input.attributes.request.http as http_request
default allow := false
allow if {
is_token_valid
action_allowed
}
is_token_valid if {
token.valid
now := time.now_ns() / 1000000000
token.payload.nbf <= now
now < token.payload.exp
}
action_allowed if {
http_request.method == "GET"
token.payload.role == "guest"
glob.match("/people", ["/"], http_request.path)
}
action_allowed if {
http_request.method == "GET"
token.payload.role == "admin"
glob.match("/people", ["/"], http_request.path)
}
action_allowed if {
http_request.method == "POST"
token.payload.role == "admin"
glob.match("/people", ["/"], http_request.path)
lower(input.parsed_body.firstname) != base64url.decode(token.payload.sub)
}
token := {"valid": valid, "payload": payload} if {
[_, encoded] := split(http_request.headers.authorization, " ")
[valid, _, payload] := io.jwt.decode_verify(encoded, {"secret": "secret"})
}
|
编译成 bundler,并用 nginx 来提供服务:
1
2
|
opa build policy.rego
docker run --rm --name bundle-server -d -p 8888:80 -v ${PWD}:/usr/share/nginx/html:ro nginx:latest
|
Playground 调试策略
OPA 官网提供了 playground 在线工具 来调试策略,对于上面的策略,有如下请求:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
{
"attributes": {
"request": {
"http": {
"method": "GET",
"path": "/people",
"headers": {
"authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiZ3Vlc3QiLCJzdWIiOiJZV3hwWTJVPSIsIm5iZiI6MTUxNDg1MTEzOSwiZXhwIjoxNjQxMDgxNTM5fQ.K5DnnbbIOspRbpCr2IKXE9cPVatGOCBrBQobQmBmaeU"
}
}
}
}
}
|
在 playground 中运行结果如下图所示:
将 authorization header 改为后面 Bob 的认证:
1
2
3
|
"headers": {
"authorization": "Bearer eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJleHAiOiAyMjQxMDgxNTM5LCAibmJmIjogMTUxNDg1MTEzOSwgInJvbGUiOiAiYWRtaW4iLCAic3ViIjogIlltOWkifQ.5qsm7rRTvqFHAgiB6evX0a_hWnGbWquZC0HImVQPQo8"
}
|
输出的结果为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
{
"action_allowed": true,
"allow": true,
"is_token_valid": true,
"token": {
"payload": {
"exp": 2241081539,
"nbf": 1514851139,
"role": "admin",
"sub": "Ym9i"
},
"valid": true
}
}
|
创建应用 deployment
应用的 deployment 包含应用容器、envoy 容器、OPA 容器,deployment.yaml 为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
|
kind: Deployment
apiVersion: apps/v1
metadata:
name: example-app
labels:
app: example-app
spec:
replicas: 1
selector:
matchLabels:
app: example-app
template:
metadata:
labels:
app: example-app
spec:
initContainers:
- name: proxy-init
image: openpolicyagent/proxy_init:v5
# Configure the iptables bootstrap script to redirect traffic to the
# Envoy proxy on port 8000, specify that Envoy will be running as user
# 1111, and that we want to exclude port 8282 from the proxy for the
# OPA health checks. These values must match up with the configuration
# defined below for the "envoy" and "opa" containers.
args: ["-p", "8000", "-u", "1111", "-w", "8282"]
securityContext:
capabilities:
add:
- NET_ADMIN
runAsNonRoot: false
runAsUser: 0
containers:
- name: app
image: openpolicyagent/demo-test-server:v1
ports:
- containerPort: 8080
- name: envoy
image: envoyproxy/envoy:v1.20.0
volumeMounts:
- readOnly: true
mountPath: /config
name: proxy-config
args:
- "envoy"
- "--config-path"
- "/config/envoy.yaml"
env:
- name: ENVOY_UID
value: "1111"
- name: opa
# Note: openpolicyagent/opa:latest-envoy is created by retagging
# the latest released image of OPA-Envoy.
image: openpolicyagent/opa:latest-envoy
args:
- "run"
- "--server"
- "--addr=localhost:8181"
- "--diagnostic-addr=0.0.0.0:8282"
- "--set=services.default.url=http://192.168.60.53:8888"
- "--set=bundles.default.resource=bundle.tar.gz"
- "--set=plugins.envoy_ext_authz_grpc.addr=:9191"
- "--set=plugins.envoy_ext_authz_grpc.path=envoy/authz/allow"
- "--set=decision_logs.console=true"
- "--set=status.console=true"
- "--ignore=.*"
livenessProbe:
httpGet:
path: /health?plugins
scheme: HTTP
port: 8282
initialDelaySeconds: 5
periodSeconds: 5
readinessProbe:
httpGet:
path: /health?plugins
scheme: HTTP
port: 8282
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: proxy-config
configMap:
name: proxy-config
|
需要说明以下几点:
- proxy-init 容器是设置 iptables 来转发流量,参数 ["-p", “8000”, “-u”, “1111”, “-w”, “8282”] 表示服务的监听端口为 8000,运行时使的用户 id 为 1111,不转发 8282 端口的流量。
- 因为不是用的 minikube 集群,OPA 中 services.default.url 需要改为 node 的内部 ip。
执行部署:
1
|
kubectl apply -f deployment.yaml
|
暴露 http 服务
官网示例中是利用 minikube tunnel 提供的 LoadBalancer 模式来暴露服务的,这里为了简单直接用 NodePort 模式:
1
2
|
kubectl expose deployment example-app --type=NodePort --name=example-app-service --port=8080
kubectl get svc example-app-service # 获取 NodePort
|
测试结果
为方便测试,设置以下几个环境变量:
1
2
3
|
export ALICE_TOKEN="eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJleHAiOiAyMjQxMDgxNTM5LCAibmJmIjogMTUxNDg1MTEzOSwgInJvbGUiOiAiZ3Vlc3QiLCAic3ViIjogIllXeHBZMlU9In0.Uk5hgUqMuUfDLvBLnlXMD0-X53aM_Hlziqg3vhOsCc8"
export BOB_TOKEN="eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJleHAiOiAyMjQxMDgxNTM5LCAibmJmIjogMTUxNDg1MTEzOSwgInJvbGUiOiAiYWRtaW4iLCAic3ViIjogIlltOWkifQ.5qsm7rRTvqFHAgiB6evX0a_hWnGbWquZC0HImVQPQo8"
export SERVICE_URL=172.19.52.61:32212 #
|
测试 Alice 可以访问员工信息但是不能创建:
1
2
|
curl -i -H "Authorization: Bearer $ALICE_TOKEN" http://$SERVICE_URL/people
curl -i -H "Authorization: Bearer $ALICE_TOKEN" -d '{"firstname":"Charlie", "lastname":"OPA"}' -H "Content-Type: application/json" -X POST http://$SERVICE_URL/people
|
测试 Bob 可以获取员工信息也能创建:
1
2
|
curl -i -H "Authorization: Bearer $BOB_TOKEN" http://$SERVICE_URL/people
curl -i -H "Authorization: Bearer $BOB_TOKEN" -d '{"firstname":"Charlie", "lastname":"Opa"}' -H "Content-Type: application/json" -X POST http://$SERVICE_URL/people
|
测试完成后,删除所有服务:
1
2
3
4
|
kubectl delete service example-app-service
kubectl delete deployment example-app
kubectl delete configmap proxy-config
docker rm -f bundle-server
|
参考