微服务认证与授权:08 — OPA(PDP)

08 --- OPA(PDP)

📦 GitHub: https://github.com/geekchow/micro-service-auth

OPA 是策略决策点(Policy Decision Point):它从 Kong 接收授权 input,并返回 allowdeny

什么是 OPA

OPA 是 Open Policy Agent 的缩写。

在本项目中,OPAPDP(策略决策点)------ 完整的 IdP / PEP / PDP 术语表见 01 --- 概念

OPA 只做一件事:

  • 接收一个结构化的 input 文档
  • 用 Rego 策略规则对其进行评估
  • 返回一个决策:allowdeny

OPA 不认证用户。那是 Keycloak 的职责。

OPA 不在边缘执行决策。那是 Kong 的职责。

OPA 只负责判定。

本项目为什么需要 OPA

授权逻辑本可以直接写在:

  • Kong 的插件代码里
  • banking-api-service 的 Java 代码里

但那样会把策略与执行或业务逻辑耦合在一起。

OPA 用作专职的 PDP 带来三点具体好处:

  1. 策略与网关、服务代码相互分离。
  2. 策略可以用 rego test 独立测试。
  3. 策略可以在不重写 Kong 插件或 banking-api-service 的情况下变更。

在本 PoC 中:

  • Keycloak 证明用户是谁。
  • Kong 在边缘执行(PEP)。
  • OPA 判定操作是否被允许(PDP)。
  • banking-api-service 提供纵深防御(资源服务器)。

OPA 在架构中的位置

#mermaid-svg-t5NZX25YiVoz9Pf5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-t5NZX25YiVoz9Pf5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-t5NZX25YiVoz9Pf5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-t5NZX25YiVoz9Pf5 .error-icon{fill:#552222;}#mermaid-svg-t5NZX25YiVoz9Pf5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-t5NZX25YiVoz9Pf5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-t5NZX25YiVoz9Pf5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-t5NZX25YiVoz9Pf5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-t5NZX25YiVoz9Pf5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-t5NZX25YiVoz9Pf5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-t5NZX25YiVoz9Pf5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-t5NZX25YiVoz9Pf5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-t5NZX25YiVoz9Pf5 .marker.cross{stroke:#333333;}#mermaid-svg-t5NZX25YiVoz9Pf5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-t5NZX25YiVoz9Pf5 p{margin:0;}#mermaid-svg-t5NZX25YiVoz9Pf5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-t5NZX25YiVoz9Pf5 .cluster-label text{fill:#333;}#mermaid-svg-t5NZX25YiVoz9Pf5 .cluster-label span{color:#333;}#mermaid-svg-t5NZX25YiVoz9Pf5 .cluster-label span p{background-color:transparent;}#mermaid-svg-t5NZX25YiVoz9Pf5 .label text,#mermaid-svg-t5NZX25YiVoz9Pf5 span{fill:#333;color:#333;}#mermaid-svg-t5NZX25YiVoz9Pf5 .node rect,#mermaid-svg-t5NZX25YiVoz9Pf5 .node circle,#mermaid-svg-t5NZX25YiVoz9Pf5 .node ellipse,#mermaid-svg-t5NZX25YiVoz9Pf5 .node polygon,#mermaid-svg-t5NZX25YiVoz9Pf5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-t5NZX25YiVoz9Pf5 .rough-node .label text,#mermaid-svg-t5NZX25YiVoz9Pf5 .node .label text,#mermaid-svg-t5NZX25YiVoz9Pf5 .image-shape .label,#mermaid-svg-t5NZX25YiVoz9Pf5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-t5NZX25YiVoz9Pf5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-t5NZX25YiVoz9Pf5 .rough-node .label,#mermaid-svg-t5NZX25YiVoz9Pf5 .node .label,#mermaid-svg-t5NZX25YiVoz9Pf5 .image-shape .label,#mermaid-svg-t5NZX25YiVoz9Pf5 .icon-shape .label{text-align:center;}#mermaid-svg-t5NZX25YiVoz9Pf5 .node.clickable{cursor:pointer;}#mermaid-svg-t5NZX25YiVoz9Pf5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-t5NZX25YiVoz9Pf5 .arrowheadPath{fill:#333333;}#mermaid-svg-t5NZX25YiVoz9Pf5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-t5NZX25YiVoz9Pf5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-t5NZX25YiVoz9Pf5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-t5NZX25YiVoz9Pf5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-t5NZX25YiVoz9Pf5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-t5NZX25YiVoz9Pf5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-t5NZX25YiVoz9Pf5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-t5NZX25YiVoz9Pf5 .cluster text{fill:#333;}#mermaid-svg-t5NZX25YiVoz9Pf5 .cluster span{color:#333;}#mermaid-svg-t5NZX25YiVoz9Pf5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-t5NZX25YiVoz9Pf5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-t5NZX25YiVoz9Pf5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-t5NZX25YiVoz9Pf5 .icon-shape,#mermaid-svg-t5NZX25YiVoz9Pf5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-t5NZX25YiVoz9Pf5 .icon-shape p,#mermaid-svg-t5NZX25YiVoz9Pf5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-t5NZX25YiVoz9Pf5 .icon-shape .label rect,#mermaid-svg-t5NZX25YiVoz9Pf5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-t5NZX25YiVoz9Pf5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-t5NZX25YiVoz9Pf5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-t5NZX25YiVoz9Pf5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} identity and token activity
allow or deny
business response
Client
Kong
Keycloak
OPA
banking-api-service

OPA 位于 Kong 与上游 banking-api-service 之间。Kong 在每个请求上同步调用 OPA、等待决策,然后要么转发请求、要么返回 403

Input → Policy → Result 模型

OPA 是一个通用引擎。它本身并不知道「银行账户」是什么。它只知道:

  1. 它收到了什么 input
  2. Rego 里写了什么规则

#mermaid-svg-nl9Op1UNjfrgbIQm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nl9Op1UNjfrgbIQm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nl9Op1UNjfrgbIQm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nl9Op1UNjfrgbIQm .error-icon{fill:#552222;}#mermaid-svg-nl9Op1UNjfrgbIQm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nl9Op1UNjfrgbIQm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nl9Op1UNjfrgbIQm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nl9Op1UNjfrgbIQm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nl9Op1UNjfrgbIQm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nl9Op1UNjfrgbIQm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nl9Op1UNjfrgbIQm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nl9Op1UNjfrgbIQm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nl9Op1UNjfrgbIQm .marker.cross{stroke:#333333;}#mermaid-svg-nl9Op1UNjfrgbIQm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nl9Op1UNjfrgbIQm p{margin:0;}#mermaid-svg-nl9Op1UNjfrgbIQm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nl9Op1UNjfrgbIQm .cluster-label text{fill:#333;}#mermaid-svg-nl9Op1UNjfrgbIQm .cluster-label span{color:#333;}#mermaid-svg-nl9Op1UNjfrgbIQm .cluster-label span p{background-color:transparent;}#mermaid-svg-nl9Op1UNjfrgbIQm .label text,#mermaid-svg-nl9Op1UNjfrgbIQm span{fill:#333;color:#333;}#mermaid-svg-nl9Op1UNjfrgbIQm .node rect,#mermaid-svg-nl9Op1UNjfrgbIQm .node circle,#mermaid-svg-nl9Op1UNjfrgbIQm .node ellipse,#mermaid-svg-nl9Op1UNjfrgbIQm .node polygon,#mermaid-svg-nl9Op1UNjfrgbIQm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nl9Op1UNjfrgbIQm .rough-node .label text,#mermaid-svg-nl9Op1UNjfrgbIQm .node .label text,#mermaid-svg-nl9Op1UNjfrgbIQm .image-shape .label,#mermaid-svg-nl9Op1UNjfrgbIQm .icon-shape .label{text-anchor:middle;}#mermaid-svg-nl9Op1UNjfrgbIQm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nl9Op1UNjfrgbIQm .rough-node .label,#mermaid-svg-nl9Op1UNjfrgbIQm .node .label,#mermaid-svg-nl9Op1UNjfrgbIQm .image-shape .label,#mermaid-svg-nl9Op1UNjfrgbIQm .icon-shape .label{text-align:center;}#mermaid-svg-nl9Op1UNjfrgbIQm .node.clickable{cursor:pointer;}#mermaid-svg-nl9Op1UNjfrgbIQm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nl9Op1UNjfrgbIQm .arrowheadPath{fill:#333333;}#mermaid-svg-nl9Op1UNjfrgbIQm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nl9Op1UNjfrgbIQm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nl9Op1UNjfrgbIQm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nl9Op1UNjfrgbIQm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nl9Op1UNjfrgbIQm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nl9Op1UNjfrgbIQm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nl9Op1UNjfrgbIQm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nl9Op1UNjfrgbIQm .cluster text{fill:#333;}#mermaid-svg-nl9Op1UNjfrgbIQm .cluster span{color:#333;}#mermaid-svg-nl9Op1UNjfrgbIQm div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nl9Op1UNjfrgbIQm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nl9Op1UNjfrgbIQm rect.text{fill:none;stroke-width:0;}#mermaid-svg-nl9Op1UNjfrgbIQm .icon-shape,#mermaid-svg-nl9Op1UNjfrgbIQm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nl9Op1UNjfrgbIQm .icon-shape p,#mermaid-svg-nl9Op1UNjfrgbIQm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nl9Op1UNjfrgbIQm .icon-shape .label rect,#mermaid-svg-nl9Op1UNjfrgbIQm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nl9Op1UNjfrgbIQm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nl9Op1UNjfrgbIQm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nl9Op1UNjfrgbIQm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Input JSON
Rego Policy
result: true or false

在本仓库中:

  • Kong 构造并发送 input
  • banking_authz.rego 定义规则
  • OPA 返回 result: trueresult: false

Rego 基础

OPA 策略用 Rego 编写,这是一门声明式语言。

你描述的是「要授予访问,必须满足什么条件」,而不是写一步步的命令式逻辑。

实践中,本仓库的规则读起来是:

  • 如果是读路由且角色是 ops-admin,则允许
  • 如果是读路由、角色是 customer、且请求的账户在令牌的 account_ids 中,则允许
  • 其余一切拒绝

默认拒绝(Deny By Default)

策略中最重要的一行是:

rego 复制代码
default allow := false

除非某条 allow 规则匹配,否则答案就是 deny。这比枚举 deny 规则更安全 ------ 一条缺失的 deny 规则永远不会意外地放开访问。

本仓库中的实际策略

文件:infra/opa/policies/banking_authz.rego

rego 复制代码
package banking_authz

default allow := false

allow {
    read_only_account_request
    input.role == "ops-admin"
}

allow {
    read_only_account_request
    input.role == "customer"
    input.customer_id != ""
    account_ids := object.get(input, "account_ids", [])
    account_ids[_] == input.account_id
}

read_only_account_request {
    input.method == "GET"
    regex.match("^/api/accounts/[^/]+(?:/transactions)?$", input.path)
}

策略逐段讲解

package banking_authz

把规则放进 banking_authz 包。Kong 在以下地址查询 OPA

复制代码
http://opa:8181/v1/data/banking_authz/allow

所以 banking_authz 是包,allow 是被查询的决策。

default allow := false

除非下面某条 allow 规则匹配,否则一切都被拒绝。

第一条 allow 规则 ------ ops-admin

rego 复制代码
allow {
    read_only_account_request
    input.role == "ops-admin"
}

含义:

  • 请求必须是合法的读路由(通过辅助规则判定)
  • 调用方必须具备角色 ops-admin
  • 不做账户归属检查 ------ ops-admin 可读取任意账户

第二条 allow 规则 ------ customer

rego 复制代码
allow {
    read_only_account_request
    input.role == "customer"
    input.customer_id != ""
    account_ids := object.get(input, "account_ids", [])
    account_ids[_] == input.account_id
}

含义:

  • 请求必须是合法的读路由
  • 调用方必须具备角色 customer
  • 令牌必须携带非空的 customer_id
  • 请求的 account_id 必须出现在令牌的 account_ids 列表中

这就是核心的客户归属检查。

辅助规则:read_only_account_request

rego 复制代码
read_only_account_request {
    input.method == "GET"
    regex.match("^/api/accounts/[^/]+(?:/transactions)?$", input.path)
}

含义:

  • 只有 GET 请求能通过此关卡
  • 只允许两种路径形态:
    • /api/accounts/{accountId}
    • /api/accounts/{accountId}/transactions

这防止未来新增的非读路由被同样的 allow 规则意外放行。

OPA 接收到什么 Input

OPA 不直接读取 HTTP 请求。Kong 构造一个结构化的 input 文档并以 JSON POST 过去。

策略中 OPA 实际消费的字段是:

字段 类型 被谁使用
method string read_only_account_request
path string read_only_account_request
role string 两条 allow 规则
customer_id string customer 的 allow 规则
account_ids 字符串数组 customer 的 allow 规则
account_id string customer 的 allow 规则

username 被包含在 input 中,但在本版本的策略规则里未被消费。

完整的声明目录见 14 --- 请求与响应细节

alice 的示例 input 文档:

json 复制代码
{
  "input": {
    "method": "GET",
    "path": "/api/accounts/A-1001",
    "account_id": "A-1001",
    "customer_id": "C-1001",
    "account_ids": ["A-1001"],
    "role": "customer",
    "username": "alice"
  }
}

Kong 如何把 Input 发给 OPA

Kong 通过 opa-authz 插件调用 OPA 的 REST API。URL 来自 infra/kong/kong.yml

yaml 复制代码
plugins:
  - name: opa-authz
    config:
      opa_url: http://opa:8181/v1/data/banking_authz/allow

插件 handler(infra/kong/plugins/opa-authz/handler.lua)构造 input 体:

lua 复制代码
local request_body = cjson.encode({
  input = {
    method = kong.request.get_method(),
    path = kong.request.get_path(),
    account_id = account_id,
    customer_id = claim_value(claims.customer_id),
    account_ids = claim_values(claims.account_ids),
    role = effective_role(claims),
    username = claims.preferred_username,
  },
})

OPA 收到的是从已校验 JWT 中提取出来的、干净的授权 input,而不是原始 HTTP 请求。

OPA 返回什么

若策略允许:

json 复制代码
{ "result": true }

若策略拒绝:

json 复制代码
{ "result": false }

Kong 把它映射为行为:

  • result: true → 把请求转发给上游 banking-api-service
  • result: false → 向客户端返回 403 Forbidden

本 PoC 中的 OPA 请求流程

banking-api-service OPA Keycloak Kong Client banking-api-service OPA Keycloak Kong Client #mermaid-svg-o6JliGwhyuz0ENgF{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-o6JliGwhyuz0ENgF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-o6JliGwhyuz0ENgF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-o6JliGwhyuz0ENgF .error-icon{fill:#552222;}#mermaid-svg-o6JliGwhyuz0ENgF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-o6JliGwhyuz0ENgF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-o6JliGwhyuz0ENgF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-o6JliGwhyuz0ENgF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-o6JliGwhyuz0ENgF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-o6JliGwhyuz0ENgF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-o6JliGwhyuz0ENgF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-o6JliGwhyuz0ENgF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-o6JliGwhyuz0ENgF .marker.cross{stroke:#333333;}#mermaid-svg-o6JliGwhyuz0ENgF svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-o6JliGwhyuz0ENgF p{margin:0;}#mermaid-svg-o6JliGwhyuz0ENgF .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-o6JliGwhyuz0ENgF text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-o6JliGwhyuz0ENgF .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-o6JliGwhyuz0ENgF .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-o6JliGwhyuz0ENgF .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-o6JliGwhyuz0ENgF .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-o6JliGwhyuz0ENgF #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-o6JliGwhyuz0ENgF .sequenceNumber{fill:white;}#mermaid-svg-o6JliGwhyuz0ENgF #sequencenumber{fill:#333;}#mermaid-svg-o6JliGwhyuz0ENgF #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-o6JliGwhyuz0ENgF .messageText{fill:#333;stroke:none;}#mermaid-svg-o6JliGwhyuz0ENgF .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-o6JliGwhyuz0ENgF .labelText,#mermaid-svg-o6JliGwhyuz0ENgF .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-o6JliGwhyuz0ENgF .loopText,#mermaid-svg-o6JliGwhyuz0ENgF .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-o6JliGwhyuz0ENgF .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-o6JliGwhyuz0ENgF .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-o6JliGwhyuz0ENgF .noteText,#mermaid-svg-o6JliGwhyuz0ENgF .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-o6JliGwhyuz0ENgF .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-o6JliGwhyuz0ENgF .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-o6JliGwhyuz0ENgF .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-o6JliGwhyuz0ENgF .actorPopupMenu{position:absolute;}#mermaid-svg-o6JliGwhyuz0ENgF .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-o6JliGwhyuz0ENgF .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-o6JliGwhyuz0ENgF .actor-man circle,#mermaid-svg-o6JliGwhyuz0ENgF line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-o6JliGwhyuz0ENgF :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt result false result true GET /api/accounts/A-1001 + JWT Introspect token active true POST policy input JSON Evaluate Rego rules result true or false 403 Forbidden forward request banking response 200 response

OPA 在 Docker Compose 中如何运行

yaml 复制代码
opa:
  image: openpolicyagent/opa:0.68.0
  command: ["run", "--server", "--addr=0.0.0.0:8181", "/policies"]
  ports:
    - "8181:8181"
  volumes:
    - ./infra/opa/policies:/policies:ro
  • OPA 作为独立的 HTTP 服务器运行在端口 8181
  • 策略以只读方式从 infra/opa/policies 挂载
  • OPA 没有被编译进任何 Java 服务 ------ 它是一个独立容器

这种分离意味着策略可以独立于 Kongbanking-api-service 进行更新、测试与重载。

测试

文件:infra/opa/policies/banking_authz_test.rego

rego 复制代码
package banking_authz_test

import data.banking_authz

允许:ops-admin 读取某账户

rego 复制代码
test_ops_admin_is_allowed {
    banking_authz.allow with input as {
        "method": "GET",
        "path": "/api/accounts/A-1001",
        "role": "ops-admin",
        "account_id": "A-1001",
        "customer_id": "C-9999",
    }
}

ops-admin 可读取任意账户端点,无论客户归属如何。

允许:customer 读取自己的账户

rego 复制代码
test_customer_can_access_owned_account {
    banking_authz.allow with input as {
        "method": "GET",
        "path": "/api/accounts/A-1001",
        "role": "customer",
        "account_id": "A-1001",
        "customer_id": "C-1001",
        "account_ids": ["A-1001"],
    }
}

当账户 A-1001 出现在 alice(客户 C-1001)的 account_ids 中时,她可读取该账户。

允许:customer 读取自己的交易

rego 复制代码
test_customer_can_access_owned_account_transactions {
    banking_authz.allow with input as {
        "method": "GET",
        "path": "/api/accounts/A-1001/transactions",
        "role": "customer",
        "account_id": "A-1001",
        "customer_id": "C-1001",
        "account_ids": ["A-1001"],
    }
}

对所拥有的账户,其交易子资源同样被允许。

拒绝用例

测试文件证明了以下所有情形会被拒绝:

测试 它证明了什么
test_customer_cannot_access_other_account account_ids 不包含请求的账户
test_customer_without_claimed_account_is_denied account_ids 为空
test_customer_without_customer_id_is_denied customer_id 字段缺失
test_ops_admin_post_account_is_denied POST 不满足 read_only_account_request
test_customer_subresource_path_is_denied /cards 路径不被正则匹配
test_other_roles_are_denied 角色 auditor 不匹配任一 allow 规则

负向测试与正向测试同等重要:只有当你也证明了它拒绝什么时,一条策略才值得信任。

实际示例

alice 读取她自己的账户

Input:

json 复制代码
{
  "input": {
    "method": "GET",
    "path": "/api/accounts/A-1001",
    "account_id": "A-1001",
    "customer_id": "C-1001",
    "account_ids": ["A-1001"],
    "role": "customer",
    "username": "alice"
  }
}

结果:allow

原因:

  • GET + 匹配的路径 → read_only_account_request 通过
  • role == "customer"
  • customer_id 非空
  • A-1001account_ids

alice 尝试访问另一个客户的账户

Input:

json 复制代码
{
  "input": {
    "method": "GET",
    "path": "/api/accounts/A-2001",
    "account_id": "A-2001",
    "customer_id": "C-1001",
    "account_ids": ["A-1001"],
    "role": "customer",
    "username": "alice"
  }
}

结果:deny

原因:

  • A-2001 不在 aliceaccount_ids["A-1001"])中

ops-admin 读取任意账户

Input:

json 复制代码
{
  "input": {
    "method": "GET",
    "path": "/api/accounts/A-2001",
    "account_id": "A-2001",
    "customer_id": "C-9999",
    "role": "ops-admin"
  }
}

结果:allow

原因:

  • ops-admin 规则不检查账户归属

POST 请求(对两种角色都拒绝)

Input:

json 复制代码
{
  "input": {
    "method": "POST",
    "path": "/api/accounts/A-1001",
    "role": "ops-admin",
    "account_id": "A-1001",
    "customer_id": "C-9999"
  }
}

结果:deny

原因:

  • read_only_account_request 失败,因为 method != "GET"

不受支持的子资源路径

Input:

json 复制代码
{
  "input": {
    "method": "GET",
    "path": "/api/accounts/A-1001/cards",
    "role": "customer",
    "account_id": "A-1001",
    "customer_id": "C-1001",
    "account_ids": ["A-1001"]
  }
}

结果:deny

原因:

  • 正则只匹配 /api/accounts/{id}/api/accounts/{id}/transactions
  • /cards 不匹配

OPA 不做什么

OPA 很强大,但在本 PoC 中它有清晰的边界:

  • 不认证用户(那是 Keycloak
  • 不签发 JWT(那是 Keycloak
  • 在本请求路径中不自己内省令牌(那是 Kong 插件)
  • 在这里不自己校验 JWT 签名(Kong 插件在内省后解码载荷)
  • 不提供银行数据(那是 banking-api-service

OPA 依赖 Kong 提供可信、格式良好的 input。如果 input 是错的,决策就是错的。

思维模型

  1. Keycloak 说明用户是谁。
  2. Kong 验证令牌处于 active 并提取声明。
  3. Kong 把结构化 input 发给 OPA
  4. OPA 评估 Rego 规则并返回 result: trueresult: false
  5. Kong 在边缘执行该决策。
  6. banking-api-service 在返回银行数据前再次校验 JWT。

← Prev: 07 --- Kong · Next: 09 --- banking-api-service


📚 返回专栏目录

相关推荐
阿标在干嘛1 小时前
100+微服务的统一出入口:政策快报平台的API网关设计
微服务·云原生·架构
摇滚侠2 小时前
云原生 Java 架构师的第一课 K8s+Docker+KubeSphere+DevOps 26-40
java·云原生·kubernetes
极***技2 小时前
2026云原生核心!Go高并发限流实战,从原理到落地
开发语言·云原生·golang
万联WANFLOW15 小时前
SD-WAN 控制平面高可用怎么做?SDWAN 控制器挂了,全网会发生什么
运维·网络·分布式·架构
踩着两条虫15 小时前
可视化 vs 终端 vs 云端:VTJ.PRO、Claude Code、Codex 三强横评
前端·vue.js·人工智能·低代码·架构
Fabarta15 小时前
从 0 实现 ChatGPT 风格的流式对话 UI
算法·架构
搭贝16 小时前
低代码平台私有化部署架构与信创全栈适配实战
低代码·架构·rxjava
学渣超17 小时前
记一次分布式事务数据不一致的排查之旅:从超时到索引,层层剥茧
java·后端·架构
小短腿的代码世界17 小时前
Qt Bluetooth源码深度解析:从HCI协议到跨平台API的完整架构
开发语言·qt·架构