This is an automated email from the ASF dual-hosted git repository.

xuetaoli pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go-pixiu.git


The following commit(s) were added to refs/heads/develop by this push:
     new 8fe7b268 Admin opa backend (#877)
8fe7b268 is described below

commit 8fe7b26841f01db0941b9d7976cc7b05cf80ed70
Author: nanjiek <[email protected]>
AuthorDate: Mon Mar 2 11:19:50 2026 +0800

    Admin opa backend (#877)
    
    * add the OPA on the admin without readme
    
    * fmt
    
    * add the description of change in readme
    
    * remove the log
    
    * fix the issue
    
    * fix the issue
    
    * delete the log and improve the yml of docker
    
    * remove the redundent -
    
    * add the newLine
    
    * fix(admin): honor OPA request timeout with context and add 30s fallback
---
 admin/API.md                                       |  75 +++++
 admin/API_CN.md                                    |  75 +++++
 admin/README.md                                    |  12 +
 admin/README_CN.md                                 |  13 +-
 admin/config/config.go                             |   7 +
 .../{web/src/api/menu-config.js => config/opa.go}  |  54 ++--
 admin/controller/opa/opa.go                        | 123 ++++++++
 admin/doc/docs.go                                  | 123 ++++++++
 admin/doc/swagger.json                             | 126 +++++++-
 admin/doc/swagger.yaml                             |  80 ++++++
 admin/initialize/router.go                         |   5 +
 admin/logic/logic.go                               | 145 ++++++++++
 admin/web/src/api/menu-config.js                   |  10 +-
 admin/web/src/router/router.js                     |   4 +
 admin/web/src/views/dashboard/manage/OPA.vue       | 319 +++++++++++++++++++++
 configs/admin_docker_config.yaml                   |   6 +
 docker-compose.yml                                 |  18 +-
 docs/images/admin/14.png                           | Bin 0 -> 49458 bytes
 docs/images/admin/15.png                           | Bin 0 -> 56099 bytes
 19 files changed, 1160 insertions(+), 35 deletions(-)

diff --git a/admin/API.md b/admin/API.md
index 0a365bb3..31f1ffaa 100644
--- a/admin/API.md
+++ b/admin/API.md
@@ -374,3 +374,78 @@ DELETE /config/api/plugin_group/?name=group1 HTTP/1.1
 Host: 127.0.0.1:8080
 cache-control: no-cache
 ```
+
+## V. OPA Policy
+
+OPA policy APIs proxy requests to the OPA server. If `server_url` or 
`policy_id` is not provided, defaults are used (`http://opa:8181` and 
`pixiu-authz`).
+
+### 5.1 Get OPA Policy
+
+**Request**:
+
+```http
+GET /config/api/opa/policy?policy_id=pixiu-authz HTTP/1.1
+Host: 127.0.0.1:8080
+cache-control: no-cache
+```
+
+**Query Params**:
+
+* `policy_id`: OPA policy id (optional)
+* `server_url`: OPA server URL (optional)
+* `bearer_token`: OPA bearer token (optional)
+
+**Response**:
+
+```json
+{
+  "code": "10001",
+  "data": "package pixiu.authz\n\ndefault allow := false\n"
+}
+```
+
+If the policy does not exist, `data` will be an empty string.
+
+### 5.2 Create or Update OPA Policy
+
+**Request**:
+
+```http
+PUT /config/api/opa/policy HTTP/1.1
+Host: 127.0.0.1:8080
+Content-Type: multipart/form-data; boundary=-WebKitFormBoundary7MA4YWxkTrZu0gW
+cache-control: no-cache
+```
+
+**Form Data**:
+
+```text
+Content-Disposition: form-data; name="policy_id"
+pixiu-authz
+
+Content-Disposition: form-data; name="content"
+package pixiu.authz
+
+default allow := false
+```
+
+Optional form fields:
+
+* `server_url`
+* `bearer_token`
+
+### 5.3 Delete OPA Policy
+
+**Request**:
+
+```http
+DELETE /config/api/opa/policy?policy_id=pixiu-authz HTTP/1.1
+Host: 127.0.0.1:8080
+cache-control: no-cache
+```
+
+**Query Params**:
+
+* `policy_id`: OPA policy id (optional)
+* `server_url`: OPA server URL (optional)
+* `bearer_token`: OPA bearer token (optional)
diff --git a/admin/API_CN.md b/admin/API_CN.md
index a4c02f11..c07c6f87 100644
--- a/admin/API_CN.md
+++ b/admin/API_CN.md
@@ -376,3 +376,78 @@ DELETE /config/api/plugin_group/?name=group1 HTTP/1.1
 Host: 127.0.0.1:8080
 cache-control: no-cache
 ```
+
+## 五、OPA 策略
+
+OPA 策略接口会代理请求到 OPA 服务端。未提供 `server_url` 或 `policy_id` 
时,会使用默认值(`http://opa:8181` 和 `pixiu-authz`)。
+
+### 5.1 获取 OPA 策略
+
+**请求**:
+
+```http
+GET /config/api/opa/policy?policy_id=pixiu-authz HTTP/1.1
+Host: 127.0.0.1:8080
+cache-control: no-cache
+```
+
+**Query 参数**:
+
+* `policy_id`: OPA policy id(可选)
+* `server_url`: OPA 服务地址(可选)
+* `bearer_token`: OPA Bearer Token(可选)
+
+**返回**:
+
+```json
+{
+  "code": "10001",
+  "data": "package pixiu.authz\n\ndefault allow := false\n"
+}
+```
+
+若策略不存在,`data` 返回空字符串。
+
+### 5.2 新增或更新 OPA 策略
+
+**请求**:
+
+```http
+PUT /config/api/opa/policy HTTP/1.1
+Host: 127.0.0.1:8080
+Content-Type: multipart/form-data; boundary=-WebKitFormBoundary7MA4YWxkTrZu0gW
+cache-control: no-cache
+```
+
+**表单数据**:
+
+```text
+Content-Disposition: form-data; name="policy_id"
+pixiu-authz
+
+Content-Disposition: form-data; name="content"
+package pixiu.authz
+
+default allow := false
+```
+
+可选表单字段:
+
+* `server_url`
+* `bearer_token`
+
+### 5.3 删除 OPA 策略
+
+**请求**:
+
+```http
+DELETE /config/api/opa/policy?policy_id=pixiu-authz HTTP/1.1
+Host: 127.0.0.1:8080
+cache-control: no-cache
+```
+
+**Query 参数**:
+
+* `policy_id`: OPA policy id(可选)
+* `server_url`: OPA 服务地址(可选)
+* `bearer_token`: OPA Bearer Token(可选)
diff --git a/admin/README.md b/admin/README.md
index 232399d3..9ae7efa2 100644
--- a/admin/README.md
+++ b/admin/README.md
@@ -277,6 +277,18 @@ After saving, the rate-limiting configuration will take 
effect.
 
 ![13.png](../docs/images/admin/13.png)
 
+### Manage OPA Policy Configuration
+
+#### Configure OPA Policy
+
+Click the "OPA Policy Configuration" menu to manage the OPA policy. You can 
change `policy_id`, synchronize the latest policy from the OPA server, and edit 
Rego policy content in the editor.
+
+![14.png](../docs/images/admin/14.png)
+
+After editing, click "Save" to upload the policy to OPA. You can also click 
"Delete" to remove the policy.
+
+![15.png](../docs/images/admin/15.png)
+
 ## III. Pixiu Remote Configuration
 
 ### Start and Configure
diff --git a/admin/README_CN.md b/admin/README_CN.md
index ed2c1843..8ca4ee65 100644
--- a/admin/README_CN.md
+++ b/admin/README_CN.md
@@ -278,6 +278,18 @@ rules:
 
 ![13.png](../docs/images/admin/13.png)
 
+### 管理 OPA 策略配置
+
+#### 配置 OPA 策略
+
+点击 "OPA 策略配置" 菜单,可以修改 `policy_id`,同步 OPA 策略,并在编辑器中编写 Rego 策略内容。
+
+![14.png](../docs/images/admin/14.png)
+
+编辑完成后,点击 "保存" 将策略提交到 OPA;也可以点击 "删除" 删除该策略。
+
+![15.png](../docs/images/admin/15.png)
+
 ## 三、Pixiu 远程配置
 
 ### 启动和配置
@@ -304,4 +316,3 @@ curl -X POST 
"http://127.0.0.1:8888/api/v1/test-dubbo/user?name=tc";
 ## 许可证
 
 本项目采用 Apache License 2.0 开源许可。
-
diff --git a/admin/config/config.go b/admin/config/config.go
index 6ad9dec2..1083ebd8 100644
--- a/admin/config/config.go
+++ b/admin/config/config.go
@@ -54,6 +54,7 @@ type AdminBootstrap struct {
        Server      ServerConfig `yaml:"server" json:"server" 
mapstructure:"server"`
        EtcdConfig  EtcdConfig   `yaml:"etcd" json:"etcd" mapstructure:"etcd"`
        MysqlConfig MysqlConfig  `yaml:"mysql" json:"mysql" 
mapstructure:"mysql"`
+       OPA         OPAConfig    `yaml:"opa" json:"opa" mapstructure:"opa"`
 }
 
 // GetAddress get etcd server address
@@ -86,6 +87,12 @@ type MysqlConfig struct {
        Dbname   string `yaml:"dbname" json:"dbname" mapstructure:"dbname"`
 }
 
+type OPAConfig struct {
+       ServerURL      string        `yaml:"server_url" json:"server_url" 
mapstructure:"server_url"`
+       PolicyID       string        `yaml:"policy_id" json:"policy_id" 
mapstructure:"policy_id"`
+       RequestTimeout time.Duration `yaml:"request_timeout" 
json:"request_timeout" mapstructure:"request_timeout"`
+}
+
 // BaseInfo base info
 type BaseInfo struct {
        Name           string `json:"name" yaml:"name"`
diff --git a/admin/web/src/api/menu-config.js b/admin/config/opa.go
similarity index 59%
copy from admin/web/src/api/menu-config.js
copy to admin/config/opa.go
index 3183c7f5..cbc1dbf3 100644
--- a/admin/web/src/api/menu-config.js
+++ b/admin/config/opa.go
@@ -14,33 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-export const menuList = [{
-  name: '网关配置',
-  id: 'Gateway',
-  children: [{
-    name: '概览',
-    id: 'Overview',
-    componentName: '/Overview'
-  },
-  {
-    name: '插件配置',
-    id: 'Plug',
-    componentName: 'Plug'
-  },{
-    name: '集群管理',
-    id: 'Cluster',
-    componentName: 'Cluster'
-  },{
-    name: 'Listener管理',
-    id: 'Listener',
-    componentName: 'Listener'
-  }]
-}, {
-  name: '限流配置',
-  id: 'Flow',
-  children: [{
-    name: '限流配置',
-    id: 'RateLimiter',
-    componentName: '/RateLimiter'
-  }]
-}]
+
+package config
+
+import (
+       "time"
+)
+
+const (
+       DefaultOPAServerURL     = "http://opa:8181";
+       DefaultOPAPolicyID      = "pixiu-authz"
+       DefaultOPAPolicyTimeout = 8 * time.Second
+)
+
+type OPAQuery struct {
+       ServerURL   string `form:"server_url"`
+       PolicyID    string `form:"policy_id"`
+       BearerToken string `form:"bearer_token"`
+}
+
+type OPAPolicyGetResponse struct {
+       Result struct {
+               Raw string `json:"raw"`
+       } `json:"result"`
+}
diff --git a/admin/controller/opa/opa.go b/admin/controller/opa/opa.go
new file mode 100644
index 00000000..1b1673fe
--- /dev/null
+++ b/admin/controller/opa/opa.go
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opa
+
+import (
+       "net/http"
+       "strings"
+)
+
+import (
+       "github.com/gin-gonic/gin"
+
+       perrors "github.com/pkg/errors"
+)
+
+import (
+       adminconfig "github.com/apache/dubbo-go-pixiu/admin/config"
+       "github.com/apache/dubbo-go-pixiu/admin/logic"
+)
+
+// @Tags Config
+// @Summary upload OPA policy (server mode)
+// @Router /config/api/opa/policy [put]
+func PutOPAPolicy(c *gin.Context) {
+       // For PUT, we typically expect Form Data
+       serverURL := resolveOPAServerURL(c.PostForm("server_url"))
+       policyID := resolveOPAPolicyID(c.PostForm("policy_id"))
+       bearerToken := c.PostForm("bearer_token")
+       content := c.PostForm("content")
+
+       if content == "" {
+               c.JSON(http.StatusOK, adminconfig.WithError(perrors.New("rego 
content is required")))
+               return
+       }
+
+       if err := logic.BizPutOPAPolicy(serverURL, policyID, bearerToken, 
content); err != nil {
+               c.JSON(http.StatusOK, adminconfig.WithError(err))
+               return
+       }
+       c.JSON(http.StatusOK, adminconfig.WithRet("Update Success"))
+}
+
+// @Tags Config
+// @Summary get OPA policy (server mode)
+// @Router /config/api/opa/policy [get]
+func GetOPAPolicy(c *gin.Context) {
+       var query adminconfig.OPAQuery
+       if err := c.ShouldBindQuery(&query); err != nil {
+               c.JSON(http.StatusOK, adminconfig.WithError(err))
+               return
+       }
+
+       serverURL := resolveOPAServerURL(query.ServerURL)
+       policyID := resolveOPAPolicyID(query.PolicyID)
+
+       result, err := logic.BizGetOPAPolicy(serverURL, policyID, 
query.BearerToken)
+       if err != nil {
+               c.JSON(http.StatusOK, adminconfig.WithError(err))
+               return
+       }
+       c.JSON(http.StatusOK, adminconfig.WithRet(result))
+}
+
+// @Tags Config
+// @Summary delete OPA policy (server mode)
+// @Router /config/api/opa/policy [delete]
+func DeleteOPAPolicy(c *gin.Context) {
+       var query adminconfig.OPAQuery
+       if err := c.ShouldBindQuery(&query); err != nil {
+               c.JSON(http.StatusOK, adminconfig.WithError(err))
+               return
+       }
+
+       serverURL := resolveOPAServerURL(query.ServerURL)
+       policyID := resolveOPAPolicyID(query.PolicyID)
+
+       if err := logic.BizDeleteOPAPolicy(serverURL, policyID, 
query.BearerToken); err != nil {
+               c.JSON(http.StatusOK, adminconfig.WithError(err))
+               return
+       }
+       c.JSON(http.StatusOK, adminconfig.WithRet("Delete Success"))
+}
+
+func resolveOPAServerURL(serverURL string) string {
+       serverURL = strings.TrimSpace(serverURL)
+       if serverURL != "" {
+               return serverURL
+       }
+       if adminconfig.Bootstrap != nil {
+               if trimmed := 
strings.TrimSpace(adminconfig.Bootstrap.OPA.ServerURL); trimmed != "" {
+                       return trimmed
+               }
+       }
+       return adminconfig.DefaultOPAServerURL
+}
+
+func resolveOPAPolicyID(policyID string) string {
+       policyID = strings.TrimSpace(policyID)
+       if policyID != "" {
+               return policyID
+       }
+       if adminconfig.Bootstrap != nil {
+               if trimmed := 
strings.TrimSpace(adminconfig.Bootstrap.OPA.PolicyID); trimmed != "" {
+                       return trimmed
+               }
+       }
+       return adminconfig.DefaultOPAPolicyID
+}
diff --git a/admin/doc/docs.go b/admin/doc/docs.go
index 87fb91ae..d37b2fcc 100644
--- a/admin/doc/docs.go
+++ b/admin/doc/docs.go
@@ -817,6 +817,129 @@ const docTemplate = `{
                 }
             }
         },
+        "/config/api/opa/policy": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Config"
+                ],
+                "summary": "get OPA policy (server mode)",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "OPA server url",
+                        "name": "server_url",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Policy ID",
+                        "name": "policy_id",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Bearer token",
+                        "name": "bearer_token",
+                        "in": "query"
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "string"
+                        }
+                    }
+                }
+            },
+            "put": {
+                "consumes": [
+                    "application/x-www-form-urlencoded"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Config"
+                ],
+                "summary": "upload OPA policy (server mode)",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "OPA server url",
+                        "name": "server_url",
+                        "in": "formData"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Policy ID",
+                        "name": "policy_id",
+                        "in": "formData"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Bearer token",
+                        "name": "bearer_token",
+                        "in": "formData"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Rego content",
+                        "name": "content",
+                        "in": "formData",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "string"
+                        }
+                    }
+                }
+            },
+            "delete": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Config"
+                ],
+                "summary": "delete OPA policy (server mode)",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "OPA server url",
+                        "name": "server_url",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Policy ID",
+                        "name": "policy_id",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Bearer token",
+                        "name": "bearer_token",
+                        "in": "query"
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "string"
+                        }
+                    }
+                }
+            }
+        },
         "/config/api/resource/publish": {
             "put": {
                 "description": "publish resources from unpublished spaces to 
published spaces",
diff --git a/admin/doc/swagger.json b/admin/doc/swagger.json
index 7b1b085e..17c9acfc 100644
--- a/admin/doc/swagger.json
+++ b/admin/doc/swagger.json
@@ -789,7 +789,129 @@
                 }
             }
         },
-        "/config/api/resource/publish": {
+        "/config/api/opa/policy": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Config"
+                ],
+                "summary": "get OPA policy (server mode)",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "OPA server url",
+                        "name": "server_url",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Policy ID",
+                        "name": "policy_id",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Bearer token",
+                        "name": "bearer_token",
+                        "in": "query"
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "string"
+                        }
+                    }
+                }
+            },
+            "put": {
+                "consumes": [
+                    "application/x-www-form-urlencoded"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Config"
+                ],
+                "summary": "upload OPA policy (server mode)",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "OPA server url",
+                        "name": "server_url",
+                        "in": "formData"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Policy ID",
+                        "name": "policy_id",
+                        "in": "formData"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Bearer token",
+                        "name": "bearer_token",
+                        "in": "formData"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Rego content",
+                        "name": "content",
+                        "in": "formData",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "string"
+                        }
+                    }
+                }
+            },
+            "delete": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Config"
+                ],
+                "summary": "delete OPA policy (server mode)",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "OPA server url",
+                        "name": "server_url",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Policy ID",
+                        "name": "policy_id",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "Bearer token",
+                        "name": "bearer_token",
+                        "in": "query"
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "string"
+                        }
+                    }
+                }
+            }
+        },        "/config/api/resource/publish": {
             "put": {
                 "description": "publish resources from unpublished spaces to 
published spaces",
                 "produces": [
@@ -964,4 +1086,4 @@
             }
         }
     }
-}
\ No newline at end of file
+}
diff --git a/admin/doc/swagger.yaml b/admin/doc/swagger.yaml
index 3848f685..3a2566f9 100644
--- a/admin/doc/swagger.yaml
+++ b/admin/doc/swagger.yaml
@@ -535,6 +535,86 @@ paths:
       summary: batch Release Method Config
       tags:
       - Config
+  /config/api/opa/policy:
+    get:
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            type: string
+      summary: get OPA policy (server mode)
+      tags:
+      - Config
+      parameters:
+      - description: OPA server url
+        in: query
+        name: server_url
+        type: string
+      - description: Policy ID
+        in: query
+        name: policy_id
+        type: string
+      - description: Bearer token
+        in: query
+        name: bearer_token
+        type: string
+    put:
+      consumes:
+      - application/x-www-form-urlencoded
+      parameters:
+      - description: OPA server url
+        in: formData
+        name: server_url
+        type: string
+      - description: Policy ID
+        in: formData
+        name: policy_id
+        type: string
+      - description: Bearer token
+        in: formData
+        name: bearer_token
+        type: string
+      - description: Rego content
+        in: formData
+        name: content
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            type: string
+      summary: upload OPA policy (server mode)
+      tags:
+      - Config
+    delete:
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            type: string
+      summary: delete OPA policy (server mode)
+      tags:
+      - Config
+      parameters:
+      - description: OPA server url
+        in: query
+        name: server_url
+        type: string
+      - description: Policy ID
+        in: query
+        name: policy_id
+        type: string
+      - description: Bearer token
+        in: query
+        name: bearer_token
+        type: string
   /config/api/resource/publish:
     put:
       description: publish resources from unpublished spaces to published 
spaces
diff --git a/admin/initialize/router.go b/admin/initialize/router.go
index 5374ce02..b20cf927 100644
--- a/admin/initialize/router.go
+++ b/admin/initialize/router.go
@@ -29,6 +29,7 @@ import (
        "github.com/apache/dubbo-go-pixiu/admin/controller/account"
        "github.com/apache/dubbo-go-pixiu/admin/controller/auth"
        "github.com/apache/dubbo-go-pixiu/admin/controller/configInfo"
+       "github.com/apache/dubbo-go-pixiu/admin/controller/opa"
        _ "github.com/apache/dubbo-go-pixiu/admin/doc"
 )
 
@@ -82,6 +83,10 @@ func Routers() *gin.Engine {
                taR.PUT("/config/api/resource/method", 
configInfo.ModifyMethodInfo)
                taR.DELETE("/config/api/resource/method", 
configInfo.DeleteMethodInfo)
 
+               taR.GET("/config/api/opa/policy", opa.GetOPAPolicy)
+               taR.PUT("/config/api/opa/policy", opa.PutOPAPolicy)
+               taR.DELETE("/config/api/opa/policy", opa.DeleteOPAPolicy)
+
                // Which request method to choose, Temporarily choose put method
                taR.PUT("/config/api/resource/publish", 
configInfo.BatchReleaseResource)
                taR.PUT("/config/api/resource/method/publish", 
configInfo.BatchReleaseMethod)
diff --git a/admin/logic/logic.go b/admin/logic/logic.go
index dab5aea3..dbdfd414 100644
--- a/admin/logic/logic.go
+++ b/admin/logic/logic.go
@@ -18,10 +18,17 @@
 package logic
 
 import (
+       "bytes"
+       "context"
+       "encoding/json"
        "errors"
+       "fmt"
+       "io"
+       "net/http"
        "regexp"
        "strconv"
        "strings"
+       "time"
 )
 
 import (
@@ -51,6 +58,7 @@ const (
        Plugin      = "plugin"
        Filter      = "filter"
        Ratelimit   = "ratelimit"
+       OPA         = "opa"
        Clusters    = "clusters"
        Listeners   = "listeners"
        Unpublished = "unpublished"
@@ -58,6 +66,22 @@ const (
        ErrID = -1
 )
 
+// Use a shared client to enable HTTP keep-alive and prevent connection 
exhaustion.
+// Timeout is enforced per request so it can honor late-loaded config.
+// Set a large fallback timeout as a last-resort guard.
+const opaHTTPClientFallbackTimeout = 30 * time.Second
+
+var opaHTTPClient = &http.Client{
+       Timeout: opaHTTPClientFallbackTimeout,
+}
+
+func getOPATimeout() time.Duration {
+       if adminconfig.Bootstrap != nil && 
adminconfig.Bootstrap.OPA.RequestTimeout > 0 {
+               return adminconfig.Bootstrap.OPA.RequestTimeout
+       }
+       return adminconfig.DefaultOPAPolicyTimeout
+}
+
 // BizGetBaseInfo get base info
 func BizGetBaseInfo() (*adminconfig.BaseInfo, error) {
        content, err := adminconfig.Client.Get(getRootPath(Base))
@@ -510,6 +534,127 @@ func BRCreate(key, value, configType string) error {
        return errors.New("")
 }
 
+// BizGetOPAPolicy fetches the policy raw text. Returns empty string if not 
found.
+func BizGetOPAPolicy(serverURL, policyID, bearerToken string) (string, error) {
+       url, err := buildOPAPolicyURL(serverURL, policyID)
+       if err != nil {
+               return "", err
+       }
+
+       status, body, err := doOPARequestWithStatus(http.MethodGet, url, 
bearerToken, "", nil)
+       if err != nil {
+               return "", err
+       }
+
+       // Handle the "initial state" where the policy doesn't exist yet
+       if status == http.StatusNotFound {
+               return "", nil
+       }
+
+       var decoded adminconfig.OPAPolicyGetResponse
+       if err := json.Unmarshal(body, &decoded); err != nil {
+               return "", perrors.WithMessage(err, "failed to decode OPA 
response")
+       }
+
+       return decoded.Result.Raw, nil
+}
+
+// BizPutOPAPolicy updates or creates a policy.
+func BizPutOPAPolicy(serverURL, policyID, bearerToken, policy string) error {
+       url, err := buildOPAPolicyURL(serverURL, policyID)
+       if err != nil {
+               return err
+       }
+
+       if strings.TrimSpace(policy) == "" {
+               return perrors.New("policy content is required")
+       }
+
+       normalized := strings.ReplaceAll(policy, "\r\n", "\n")
+       _, _, err = doOPARequestWithStatus(http.MethodPut, url, bearerToken, 
"text/plain", []byte(normalized))
+       return err
+}
+
+// BizDeleteOPAPolicy removes a policy. Returns nil if policy is already gone.
+func BizDeleteOPAPolicy(serverURL, policyID, bearerToken string) error {
+       url, err := buildOPAPolicyURL(serverURL, policyID)
+       if err != nil {
+               return err
+       }
+
+       _, _, err = doOPARequestWithStatus(http.MethodDelete, url, bearerToken, 
"", nil)
+       return err
+}
+
+func buildOPAPolicyURL(serverURL, policyID string) (string, error) {
+       serverURL = strings.TrimSpace(serverURL)
+       policyID = strings.TrimSpace(policyID)
+
+       if serverURL == "" || policyID == "" {
+               return "", perrors.New("server_url and policy_id are required")
+       }
+
+       base := strings.TrimRight(serverURL, "/")
+       return fmt.Sprintf("%s/v1/policies/%s", base, policyID), nil
+}
+
+// doOPARequestWithStatus is the core proxy function
+func doOPARequestWithStatus(method, url, bearerToken, contentType string, body 
[]byte) (int, []byte, error) {
+       var reader io.Reader
+       if body != nil {
+               reader = bytes.NewReader(body)
+       }
+
+       ctx := context.Background()
+       if adminconfig.Client != nil {
+               ctx = adminconfig.Client.GetCtx()
+       }
+       if timeout := getOPATimeout(); timeout > 0 {
+               var cancel context.CancelFunc
+               ctx, cancel = context.WithTimeout(ctx, timeout)
+               defer cancel()
+       }
+       req, err := http.NewRequestWithContext(ctx, method, url, reader)
+       if err != nil {
+               return 0, nil, perrors.Wrap(err, "failed to create OPA request")
+       }
+
+       if contentType != "" {
+               req.Header.Set("Content-Type", contentType)
+       }
+       if token := strings.TrimSpace(bearerToken); token != "" {
+               req.Header.Set("Authorization", "Bearer "+token)
+       }
+
+       resp, err := opaHTTPClient.Do(req)
+       if err != nil {
+               return 0, nil, perrors.Wrap(err, "OPA connection failed")
+       }
+       defer resp.Body.Close()
+
+       respBody, err := io.ReadAll(resp.Body)
+       if err != nil {
+               logger.Warnf("failed to read OPA response body: %v", err)
+       }
+
+       switch resp.StatusCode {
+       case http.StatusOK:
+               return resp.StatusCode, respBody, nil
+
+       case http.StatusNotFound:
+               if method == http.MethodGet || method == http.MethodDelete {
+                       return resp.StatusCode, nil, nil
+               }
+               return resp.StatusCode, nil, perrors.Errorf("OPA endpoint not 
found: %s", url)
+
+       default:
+               if resp.StatusCode < http.StatusOK || resp.StatusCode >= 
http.StatusMultipleChoices {
+                       return resp.StatusCode, nil, perrors.Errorf("OPA status 
%d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
+               }
+               return resp.StatusCode, respBody, nil
+       }
+}
+
 func getResourceKey(path string, unpublished bool) string {
        if unpublished {
                return getUnpublishedRootPath(Resources) + "/" + path
diff --git a/admin/web/src/api/menu-config.js b/admin/web/src/api/menu-config.js
index 3183c7f5..f12d17ae 100644
--- a/admin/web/src/api/menu-config.js
+++ b/admin/web/src/api/menu-config.js
@@ -43,4 +43,12 @@ export const menuList = [{
     id: 'RateLimiter',
     componentName: '/RateLimiter'
   }]
-}]
+}, {
+  name: 'OPA配置',
+  id: 'OPAConfig',
+  children: [{
+    name: 'OPA配置',
+    id: 'OPA',
+    componentName: '/OPA'
+  }]
+}]
diff --git a/admin/web/src/router/router.js b/admin/web/src/router/router.js
index 8c6e0012..4ea557a9 100644
--- a/admin/web/src/router/router.js
+++ b/admin/web/src/router/router.js
@@ -63,6 +63,10 @@ export default new Router({
           path: 'RateLimiter',
           component: () => import('@/views/dashboard/manage/RateLimiter.vue')
         },
+        {
+          path: 'OPA',
+          component: () => import('@/views/dashboard/manage/OPA.vue')
+        },
         {
           path: 'personInfo',
           component: () => import('@/views/dashboard/personInfo/index.vue')
diff --git a/admin/web/src/views/dashboard/manage/OPA.vue 
b/admin/web/src/views/dashboard/manage/OPA.vue
new file mode 100644
index 00000000..3b4f0249
--- /dev/null
+++ b/admin/web/src/views/dashboard/manage/OPA.vue
@@ -0,0 +1,319 @@
+<!--
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ -->
+<template>
+  <CustomLayout>
+    <div class="custom-body">
+      <div>
+        <CommonTitle title="OPA 策略配置"></CommonTitle>
+      </div>
+      <div class="custom-tools">
+        <div class="table-head">
+          <div class="custom-tools__info">OPA 策略</div>
+          <div>
+            <el-button size="mini" @click="handleSync">同步</el-button>
+            <el-button type="primary" size="mini" 
@click="handleSave">保存</el-button>
+            <el-popconfirm
+              title="确定要删除该策略吗?"
+              @confirm="handleDelete">
+              <el-button slot="reference" type="danger" 
size="mini">删除</el-button>
+            </el-popconfirm>
+          </div>
+        </div>
+        <div class="custom-tools__content">
+          <el-form :model="form"
+                   :inline="true"
+                   @submit.native.prevent=""
+                   class="table-form bg-gray"
+                   label-width="130px">
+            <el-row>
+              <el-form-item label="policy_id">
+                <el-input v-model="form.policy_id"
+                          clearable
+                          placeholder="pixiu-authz"></el-input>
+              </el-form-item>
+              
+            </el-row>
+            <el-row>
+              <div style="clear: both; height: 300px;width: 100%;" 
id="policyEditor" ref="policyEditor"/>
+            </el-row>
+          </el-form>
+        </div>
+      </div>
+    </div>
+  </CustomLayout>
+</template>
+
+<script>
+import CommonTitle from '@/components/common/CommonTitle'
+import CustomLayout from '@/components/common/CustomLayout.vue'
+import * as monaco from 'monaco-editor/esm/vs/editor/editor.main.js'
+import 
'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
+import { getLocalStorage, setLocalStorage } from '@/utils/auth'
+
+const POLICY_CACHE_KEY = 'opaPolicyLast'
+const DEFAULT_POLICY_ID = 'pixiu-authz'
+
+const DEFAULT_POLICY = `package pixiu.authz
+
+default allow := false
+
+allow if {
+  # write your logic here
+}
+`
+const CONTROL_CHAR_REGEX = 
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u2028\u2029\uFEFF]/g
+let regoRegistered = false
+
+function normalizePolicyText(value) {
+  if (!value) {
+    return ''
+  }
+  return value
+    .replace(/\r\n/g, '\n')
+    .replace(/\r/g, '\n')
+    .replace(CONTROL_CHAR_REGEX, '')
+}
+
+function registerRegoLanguage(monaco) {
+  if (regoRegistered) {
+    return
+  }
+  regoRegistered = true
+  monaco.languages.register({ id: 'rego' })
+  monaco.languages.setMonarchTokensProvider('rego', {
+    keywords: [
+      'package', 'default', 'import', 'as', 'with', 'not', 'some',
+      'else', 'if', 'in', 'true', 'false', 'null'
+    ],
+    operators: [
+      '=', ':=', '==', '!=', '<', '>', '<=', '>=', '+', '-', '*', '/',
+      '%', 'and', 'or'
+    ],
+    tokenizer: {
+      root: [
+        [/[a-zA-Z_][\w\-]*/, {
+          cases: {
+            '@keywords': 'keyword',
+            '@default': 'identifier'
+          }
+        }],
+        [/[{}()[\]]/, '@brackets'],
+        [/[=><!]=?/, 'operator'],
+        [/\"([^\"\\]|\\.)*$/, 'string.invalid'],
+        [/\"/, { token: 'string.quote', bracket: '@open', next: '@string' }],
+        [/#[^\r\n]*/, 'comment'],
+        [/\d+(\.\d+)?/, 'number'],
+        [/[;,.]/, 'delimiter']
+      ],
+      string: [
+        [/[^\\"]+/, 'string'],
+        [/\\./, 'string.escape'],
+        [/\"/, { token: 'string.quote', bracket: '@close', next: '@pop' }]
+      ]
+    }
+  })
+}
+
+export default {
+  name: 'OPAPolicyConfig',
+  components: {
+    CommonTitle,
+    CustomLayout
+  },
+  data () {
+    return {
+      form: {
+        policy_id: DEFAULT_POLICY_ID
+      },
+      monacoEditor: null
+    }
+  },
+  mounted () {
+    this.$nextTick(() => {
+      this.initPolicyEditor()
+      this.handleSync(false)
+      window.addEventListener('resize', this.handleEditorResize)
+    })
+  },
+  methods: {
+    handleEditorResize() {
+      if (this.monacoEditor) {
+        this.monacoEditor.layout()
+      }
+    },
+    initPolicyEditor() {
+      registerRegoLanguage(monaco)
+      let cached = getLocalStorage(POLICY_CACHE_KEY)
+      let value = cached && cached !== '' ? cached : DEFAULT_POLICY
+      this.monacoEditor = 
monaco.editor.create(document.getElementById('policyEditor'), {
+        value,
+        language: 'rego',
+        codeLens: true,
+        selectOnLineNumbers: true,
+        roundedSelection: false,
+        readOnly: false,
+        lineNumbers: 'on',
+        theme: 'vs-dark',
+        wordWrapColumn: 120,
+        folding: false,
+        showFoldingControls: 'always',
+        wordWrap: 'wordWrapColumn',
+        cursorStyle: 'line',
+        automaticLayout: true
+      })
+      const model = this.monacoEditor.getModel()
+      if (model) {
+        model.setEOL(monaco.editor.EndOfLineSequence.LF)
+      }
+      monaco.editor.remeasureFonts()
+      this.monacoEditor.layout()
+    },
+    handleSync(showMessage = true) {
+      this.$get('/config/api/opa/policy', {
+        policy_id: this.form.policy_id || DEFAULT_POLICY_ID
+      })
+        .then((res) => {
+          if (res) {
+            let content = ''
+            if (typeof res === 'object') {
+              if (res.code == 10001) {
+                content = res.data || ''
+              } else if (res.result && typeof res.result.raw === 'string') {
+                content = res.result.raw
+              } else if (typeof res.data === 'string') {
+                content = res.data
+              }
+            } else if (typeof res === 'string') {
+              content = res
+            }
+            if (content.trim() === '') {
+              this.setEditorValue(DEFAULT_POLICY)
+              if (showMessage) {
+                this.$message({
+                  type: 'warning',
+                  message: '当前无策略,请编写并部署',
+                })
+              }
+            } else {
+              this.setEditorValue(content)
+            }
+          }
+        })
+        .catch((err) => {
+          console.error(err)
+          this.$message({
+            type: 'error',
+            message: '同步失败,请稍后重试',
+          })
+        })
+    },
+    handleDelete() {
+      this.$delete('/config/api/opa/policy', {
+        policy_id: this.form.policy_id || DEFAULT_POLICY_ID
+      })
+        .then((res) => {
+          if (res.code == 10001) {
+            this.setEditorValue(DEFAULT_POLICY)
+            this.$message({
+              type: 'success',
+              message: '删除成功',
+            })
+          }
+        })
+        .catch((err) => {
+          console.log(err)
+        })
+    },
+    setEditorValue(value) {
+      if (this.monacoEditor) {
+        this.monacoEditor.setValue(value)
+        const model = this.monacoEditor.getModel()
+        if (model) {
+          model.setEOL(monaco.editor.EndOfLineSequence.LF)
+        }
+        this.$nextTick(() => {
+          this.monacoEditor.layout()
+        })
+      }
+    },
+    handleSave() {
+      let formData = new FormData()
+      let policy = this.monacoEditor ? this.monacoEditor.getValue() : ''
+      if (!policy || policy.trim() === '') {
+        this.$message({
+          type: 'warning',
+          message: '策略内容不能为空',
+        })
+        return
+      }
+      policy = normalizePolicyText(policy)
+      formData.append('content', policy)
+      formData.append('policy_id', this.form.policy_id || DEFAULT_POLICY_ID)
+      this.$put('/config/api/opa/policy', formData)
+        .then((res) => {
+          if (res.code == 10001) {
+            setLocalStorage(POLICY_CACHE_KEY, policy)
+            this.$message({
+              type: 'success',
+              message: '保存成功',
+            })
+          }
+        })
+        .catch((err) => {
+          console.log(err)
+        })
+    }
+  },
+  destroyed() {
+    if (this.monacoEditor) {
+      this.monacoEditor.dispose()
+    }
+    window.removeEventListener('resize', this.handleEditorResize)
+  }
+}
+</script>
+
+<style scoped lang="less">
+.custom-panel{
+  margin-top: 20px;
+}
+.custom-tools__info{
+  color: rgba(16, 16, 16, 100);
+  font-size: 18px;
+  text-align: left;
+  margin-top: 10px;
+}
+.custom-tools__content{
+  background-color: #fff;
+  margin-top: 10px;
+  padding: 10px 20px;
+}
+.table-head{
+  display: flex;
+  margin-top: 10px;
+  justify-content: space-between;
+}
+
+</style>
+<style lang="less">
+#policyEditor {
+  height: 100%;
+  width: 100%;
+  overflow: hidden;
+  font-family: Consolas, "Courier New", monospace;
+}
+</style>
diff --git a/configs/admin_docker_config.yaml b/configs/admin_docker_config.yaml
index f42f8bc6..e73ce876 100644
--- a/configs/admin_docker_config.yaml
+++ b/configs/admin_docker_config.yaml
@@ -33,3 +33,9 @@ system:
   env: 'public'
   addr: 8081
   db-type: 'mysql'
+
+# opa configuration
+opa:
+  server_url: http://opa:8181
+  policy_id: pixiu-authz
+  request_timeout: 8s
diff --git a/docker-compose.yml b/docker-compose.yml
index 1d2283f5..293cbfff 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -35,6 +35,20 @@ services:
     networks:
       - app_network
 
+  # OPA service
+  opa:
+    image: openpolicyagent/opa:1.13.1
+    container_name: pixiu_admin_opa
+    ports:
+      - "8181:8181"
+    networks:
+      - app_network
+    command:
+      - "run"
+      - "--server"
+      - "--addr=:8181"
+      - "--log-level=info"
+
   mysql_pixiu:
     image: mysql:8
     container_name: pixiu_admin_mysql
@@ -65,6 +79,7 @@ services:
       - app_network
     depends_on:
       - etcd  # Ensure etcd is ready before starting the backend
+      - opa  # Ensure opa is ready before starting the backend
       - backend # backend is the xds server for pixiu
     entrypoint: ["/bin/sh", "-c", "echo 'Waiting for backend to be ready on 
port 8081...' && \
       until nc -z pixiu_admin_go_backend 8081; do \
@@ -117,4 +132,5 @@ services:
 
 networks:
   app_network:
-    driver: bridge
\ No newline at end of file
+    driver: bridge
+
diff --git a/docs/images/admin/14.png b/docs/images/admin/14.png
new file mode 100644
index 00000000..d131e8cd
Binary files /dev/null and b/docs/images/admin/14.png differ
diff --git a/docs/images/admin/15.png b/docs/images/admin/15.png
new file mode 100644
index 00000000..b5f20020
Binary files /dev/null and b/docs/images/admin/15.png differ


Reply via email to