背景

工作中的一个 IAM(身份与访问管理)服务中,使用到了 OPA(开放策略代理)进行鉴权,针对前端来的一个请求,主要处理逻辑如下图红色箭头所示: opa_model

下面对各个步骤进行说明:

  • 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-service

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 中运行结果如下图所示: 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

参考